キャンバスとNaver地図APIを連携するまでの過程
🎯 このドキュメントを読んだ後の状態
- プロジェクトと問題に関するコンテキストを理解した。
- 地図とキャンバスを連携するためにどのような設計過程を経たか理解した。
- Naver地図とキャンバスを連携する過程で経験した困難と解決のための試みを知った。
- 最終的にどのようにNaver地図とキャンバスを連携したかを理解した。
🤔 背景
現在、Naverブーストキャンプ9期に参加して学んでいる。
いつの間にか最後のグループプロジェクトをすることになり、「位置情報サービス」をテーマに選定して進めることになった。
「位置情報サービス」という大きなカテゴリの中で、詳細テーマを決めて進める必要があった。
事業性やアイデアの独創性よりは、本当に「私たちが使いそうな、私たちの周りにある問題を解決してみよう」という趣旨で始まったテーマだ。
テーマは「中高年層のためのアクセシビリティに基づいた位置情報サービス」で、核心は地図の上にキャンバスを表示し、そこに子供が経路を表示して親に伝えることだ。
Naverブーストキャンプ9期グループプロジェクト - DDara
理解を助けるために、まず私たちのプロジェクトに関するGitHubリンクを残す。
🤔 どのように始めるか?
私がチームで担当することになった仕事は、地図とキャンバス間の連携だった。
Naver地図とキャンバス両方とも初めてだったので、どのように連携させるかについて悩みが多かった。
だから最初にしたことは資料調査だった。
まず、これらの技術が何で、どのように動作するかを知ってこそ設計ができたからだ。

見つけた資料の一部で、一度整理した後に残った資料だ。
Google検索で出てくるあらゆる使用資料と、Naver地図チームのQnA、タイプ関連ではタイプGitHubなど多くの資料を探したようだ。
関連してもしかしたら役に立つかもしれないので、いくつかの参考資料を残す。
- ズームレベルについては、Naver地図は1〜21段階を使用しており、1〜6段階はすべて同じ段階として扱う。抽象的には16段階が正しいが、厳密に言えば21段階だ。
🧑💻 一緒に見つけた方向性
該当の資料調査を基に、同僚と一緒に会議を行った。
どのような観点からアプローチすべきか、どのような機能が必要かなどを一緒に議論した。
特に、地図とキャンバスを別々のレイヤーとして扱うことにしたので、レイヤー間でどのようなインターフェースを基に扱うかを一緒に議論した。
最初は次のような形を考えていた。
これを基に考えていたが、同僚が素晴らしい解決策をくれた。
緯度と経度だけでデータを作ろう。
そしてこれを基にキャンバスでは座標に変換して出力しようという意味だった。
背景となる地図がキャンバス上に描かれる絵の基準になっており、両者を連携するには何らかの基準点が必要だが、それを緯度と経度にしてはどうかという意見からだった。
最初は理解がよくできなかったので、議論の過程で本当に多くの疑問と質問を投げかけた。
単純に言葉で表現しようとすると、お互いに誤解する部分も多く、誤解する部分もあった。
そこで、絵を描きながら構造を議論し、考えが同期されて明確に理解できた。

このように視覚的に一緒に合わせていくと、単純なインターフェースを超えてどのように実装すべきかが見え始めた。
そうして導き出した内容は次のとおりだ。
- 地図とキャンバスは別々のレイヤーとして置き、レイヤー間では緯度と経度を送受信するインターフェースを作ろう。
- キャンバスでは地図の緯度と経度を受け取り、キャンバスの座標に変換して出力しよう。
- データは(緯度、経度)のみを扱おう。
- ドラッグイベント時には、それぞれ同じ変化量だけ動けばよいだろう。
- 拡大縮小時には同じ比率で拡大縮小すればよいだろう。
このように一緒に議論し、方向性を決めた。
🖼️ 作業プロセス抽出
方向性が決まり、本格的に作業に着手することになった。
そして、最初にしたことは、これを実装するためにどのような過程を経るべきかを把握することだった。

まだどのような機能が必要か、特定の問題を解決するためにはどれくらいの時間が必要か見積もりが立っていない状況だった。
だから、既存の開発経験を基に素早く予想される作業を列挙し、順序を整理した。そしてその過程で必要だと思われるものを書き留めた。
📝 設計に対する計画の策定

**「道を先に見てから開発に着手しよう。」**という開発哲学であり習慣がある。
完璧であるよりは本当に純粋に道を見る用途としての設計。そのため設計は最大限シンプルに、そして迅速に進める必要があった。
これもまた大まかな絵を描かずに設計に入る場合、時間がかかるリスクがあった。
そこで素早く何について考慮すべきかを列挙し、これに対して順序を決めた。
📝 本格的な設計の開始 :: 内容分析
設計に入りながら、最初にしたことはプロジェクト自体での地図とキャンバスのより詳細な構造を把握することだった。
これを把握するために、同僚たちと多くの話をした。私が考えていることが合っているか、同僚が考えていることは何かなど、頻繁な相互同期の時間を持った。
そうしてプロジェクトについてお互いが考えていることを一致させ、この内容をもとに次のような絵を描いた。

このように作成しながら関連する要素の把握も進めた。

上記のような過程を経て地図とキャンバスの構造を把握した。
キャンバスの場合はノマドコーダーさんのキャンバス講義を必要な部分だけ素早く聞きながら把握しようとし、Naver地図は例題を参考にした。
これを通じて、それぞれが動作するためにはどのようなデータが必要で、何が求められるかを素早く把握することができた。
これだけでなく、直接Naver APIを分析しながらどのように動作するか把握もしたが、このように把握した内容は以下のとおりだ。
⚙️ キャンバス関連要素分析
- キャンバスは左上が(0, 0)であり、右に行くとxが増加し、下に行くとyが増加する。
- 経度(Longitude)はキャンバス上のX座標に対応する。
- 緯度(Latitude)はキャンバス上のY座標に対応する。
- キャンバスのサイズは固定されており、その内部に描かれる要素のみが変化する。
- キャンバスに関連するイベントはキャンバス内部でのみ発生する。
- キャンバスは基本的なHTML関連イベントがマッピングされているが、グラフィックに対する動作はJSで実装する必要がある。
⚙️ 地図関連要素分析
- 地図はNaver地図APIを使用する。
- 地図は緯度と経度に基づいている。
- 各要素はタイル形式で実装されている。(タイルは地図の一部分を意味する。)
- 地図はキャンバスとは異なり、地図自体でイベントを持っており、イベントが発生すると地図自体でイベントハンドラーが動作する。
- 地図は
WrapperとなるHTMLタグ要素より少し大きいサイズがレンダリングされる。(地図のサイズは地図のサイズを意味し、Wrapperは地図を囲むHTMLタグを意味する。) - ドラッグやズームイン/ズームアウトイベントで事前にレンダリングされた範囲を超えると再レンダリングされる。

上記のように、地図はタイル形式で構成されており、各タイルはimgタグで作成されていた。
イベントやその他の動作を処理するにあたって、既存の地図が持っているイベントを上書きする方式も考えてみたが、各画像タグを一つ一つ操作したり、関連する処理ロジックまで考慮するのはやりすぎだという考えがした。また、プロジェクトの目標とも合わない問題があった。
これに従って、地図とキャンバスを別々のレイヤーとして置き、レイヤー間では緯度と経度を送受信するインターフェースを作ろうという初期企画方向で、設計を固め、進めた。
📝 地図とキャンバスの連携構造設計
チームの究極的な目標はモジュール化だった。
拡張まで考慮した時、私たちのチームが究極的に目指すところは、地図に連携されるキャンバスを他の場所でも使用できるようにすることだった。
地図とは別に地図と連携するロジックと、キャンバスをオープンソースにして配布するのが目標だったと言える。
これのためには地図と残りの構造間の依存性を最大限に切る必要があった。

悩んだ末に設計した構造は上記のとおりだ。
キャンバスと地図が連携した要素を一つのコンポーネントとして確立する。
ユーザー(他の開発者)はキャンバスと地図がどのように連携しているか知る必要がない。
キャンバスに絵を描いて、動かしたりズームイン/ズームアウトが地図と連携された、この機能だけ使えればいい。
そこで、キャンバスと地図を一つにまとめて<CanvasWithMap>というコンポーネントでファサードパターンでまとめ、必要な動作は外部からpropsやイベントなどで提供する。
また、地図の場合もあくまで背景の役割のみを果たす。どの地図に入れ替えようと、<CanvasWithMap>は知る必要がなく、地図に対して同一のインターフェースで機能すればいい。
これに従って、地図とその他の詳細要素をストラテジーパターンで入れ替えられるように設計した。
これを通じて、地図とキャンバスの連携過程でそれぞれの責任をより明確にすることができた。
📝 キャンバスと地図の座標変換過程設計
地図とキャンバスについてすでに本格的な設計の開始 :: 内容分析で1次的に分析したことがある。
座標変換は追加的な分析が必要だった。
まず当面の完成のためにNaver地図を基準としたので、Naver地図の座標変換を先に考えてみた。
これに関連してNaver地図APIを本当に深く調べ、その内容は以下のとおりだ。
上記のような発想を通じて考えていたが、結果的には座標演算はキャンバスで毎回行わせ、マッピング過程においては再レンダリングを利用することにした。
これについては後で再び扱いたいと思う。
📝 コンポーネントz-index設計

初期に説明したように、キャンバスと地図はそれぞれのレイヤーで構成されており、各レイヤーはz-indexを通じて重なる。
これについてより詳細にz-indexをどのように設定するか進めた。
📝 ローディングから動作までの流れ設計
設計をする過程の中で継続して実装案を模索しながら、単純に連携だけ考えるのではなくローディングなども考慮する必要があることに気づいた。
Naver地図オブジェクトは非同期で取得するが、これを扱うのはローディング直後に表示される必要があるため、このような順序も考慮が必要だった。
そこで前に言及した直接例題を通じて実装しながら各要素がこうなるんだなと把握すると同時に、メンターとのメンタリングを通じて不足している点を補完して次のような設計ができた。

ローディング段階ではデータ処理の流れを考慮する必要があったので、非同期で進行されても、一連の流れを持つように構成した。(async, await)
セッティングの場合はローディングした資料をもとに地図を出力し、キャンバスを出力する。
この過程でローディングが長くかかる場合はローディング画面を出力する。
ユーザー動作の場合は権限によって異なるように動作するようにし、これは今後考慮するようにする。
📝 イベント発生時に地図とキャンバスの位置をどのように同期させるかについて
Naver地図の場合、急激な位置変化またはズームイン/ズームアウト時にローディングが必要なことを把握した。
あらかじめ必要な部分だけダウンロードしておいて、ユーザーのインタラクションに応じてすぐ次の領域を取得する形式だ。
上記のようにテストしながら動作を把握してみた。
そしてこれをもとに、ローディングが進行される時に頂点の座標を取得してこの時に地図とキャンバス間の同期を行えば、ユーザーの使い勝手も改善し、微妙に地図とキャンバスが同期されない問題が生じても対応できるのではないかと思った。
念のためゆっくり動かす場合も確認した結果、これと全く同じロジックが適用されることを発見した。
これに基づいて考えてみた時、イベントを検知して随時演算をするよりは該当のローディング時間に合わせて再演算をする過程も良いオプションのようだった。ただしこれに対する部分は直接実装をしながらより適切な方法で進めようとした。
基本的に頂点の緯度経度を基準に座標計算することを核心に置きつつ、次のような順序を考慮した。
- ズームイン/ズームアウトイベント発生または移動に対するイベント発生
- イベントが終わった時点を基準に地図に変化
- 地図から頂点座標を取得
- 取得した座標をもとにキャンバス再演算
- 画面に出力
上記の順序でイベント発生時に画面とキャンバスを同期させることにした。
📝 実装順序に対する設計
最後に実装順序を決めて実装に入ることにした。この時考慮した順序は以下のとおりだった。
- 地図とキャンバスを重ねたレイヤーとして出力
- 特定の位置(現在位置または任意の位置基準)を中心として地図出力
- Naver地図の頂点座標を取得するロジック実装
- キャンバスの頂点とNaver地図の頂点の緯度経度をマッピングするロジック実装
- 緯度経度をもとにキャンバスに点を表示できるように演算する関数実装
- 演算された値をもとに画面に座標を出力するロジック実装
- イベントが発生した時に4番から再計算するロジック実装
🧑💻 Z-index設定
設計が終わって最初にしたことはz-index設定だった。
tailwind.config.jsであり、まずは機能テストのためにハードコード形式で素早くZ-indexを設定できるようにした。
🧑💻 キャンバスと地図を重ねて表示する
その次にしたことは地図を実装したことだった。以下は地図のコードだ。
そしてレイヤーの基本となるキャンバスを実装した。
キャンバスには意図的に四角形を配置した。 これを通じてキャンバスが正しく表示されているか確認しようとした。キャンバスの背景色を透明にし、画面いっぱいに埋めて地図と重なって見えるようにした。
そして上記のようにキャンバスを実装し、CanvasWithMapというコンテナで地図と一緒に囲んで表示できるようにした。
🧑💻 技術的な困難:どのように重なっているレイヤーにイベントを発生させるか?
先ほど地図とキャンバスを重ねる過程までは順調に進んだ。
しかし、思いもよらない所で大きな困難がやってきた。
上記のtop-childとbottom-childのように重なっているレイヤーに対してどのように同時に同じイベントを発生させるかという困難があった。

私たちのサービスでは地図は上記のように動作する必要があった。
地図とキャンバスが重なっている時、地図とキャンバス両方にイベントが発生する必要があった。
そして、私たちが処理する必要があるイベントは以下のとおりだった。
- クリック
- ドラッグ
- ズームイン/ズームアウト
この3つのイベントだった。
実は最初はあまり気にせず非常に簡単に考えていた。

当然top-childをクリックするとすぐ下のレイヤーであるbottom-childにもそのイベントが適用されると思ったからだ。
しかし、これは意図どおりに動作しなかった。top-childには正常にイベントが適用されるが、bottom-childには適用されなかったからだ。
🤔 原因分析:スタッキングコンテキスト(Stacking Context)
原因はスタックコンテキスト(Stacking Context)による問題だった。
z-indexとスタックコンテキスト(重ね合わせコンテキスト)
スタックコンテキスト自体に関しては以前に書いた上記の記事を参考にすると良い。
CSSで要素が画面に表示されるスタックコンテキストというものが存在する。z-indexを設定すると、高いz-index値を持つ要素は低い値を持つ要素より前に位置するようになる。
そして、ブラウザはクリックイベントが発生した時に最も上にある要素からイベントを処理する。
top-childが上にあるので、ここにイベントを最初に渡す。そしてtop-childがこのイベントを横取りして解決した状態で、stopPropgationのような要素でイベント伝達をブロックして生じる問題だった。
実際に、私たちがヘッダーなどをabsoluteで実装するとここにだけイベントが適用され、他の所には適用されないが..これを見落としていたのだ。
🧑💻 1次試行:pointer-event属性をオフにする
最初の試行は上にある要素であるtop-childのpointer-event属性をオフにすることだった。
pointer-events - CSS: Cascading Style Sheets | MDN
pointer-eventとはCSSの属性の一つで、要素がマウスクリック、タッチ、カーソル移動のようなポインターイベントを受け取れるかどうかを制御する属性だ。
単純に考えて、一番上にある要素がイベントを全部食べているなら、それをオフにすればいいのでは?という考えからだった。
私たちはtailwindcssを使用していたので、次のようにpointer-event-noneクラスを付けるだけでオフにできた。
このようにするとtop-childのすべてのポインターイベントをオフにできる。

pointer-event-noneを設定すると、top-childはイベントを受け取らなくなり、bottom-childはイベントを受け取るようになる。
しかし、これは私たちが望むものではなかった。私たちはtop-child、bottom-child両方に同じイベントが同時に発生することを望んでいたからだ。
🧑💻 2次試行:子要素に直接イベントをトリガーする
2番目の試行はcontainerでイベントが発生したら、これをtop-childとbottom-childでトリガーする方式だった。

クリックを基準にだけ考えており、クリックイベントがcontainerに発生したらこれをtop-childとbottom-childにトリガーする方式だった。
これのために次のようにコードを実装した。
このようにすると、containerにクリックイベントが発生したらtop-childとbottom-childにクリックイベントをトリガーできるようになる。
ただし、これには2つの問題点があった。
refを通じた方式はHTMLElementの基本イベントのみトリガーできる。click()のようにイベントをトリガーする場合、イベントを発生させることはできるが、マウスがクリックされた位置などに対する付加情報を渡すことができない。
UIイベントは上記のように定義されており、イベントオブジェクトにはマウスがクリックされた位置、クリックされたボタンなどに対する付加情報が含まれている。
ただし公式ドキュメントを見ると、サポートするTriggerが多くない。
私たちはNaver地図および様々な地図への連携を考える必要があったため、もう少し多様なイベントを生成して渡す必要があった。
単純に様々な地図に対応するだけでなく、Naver地図など特定の地図だけを見ても、カスタムイベントを作成して提供していたからだ。
また、click()のようなメソッドを通じてイベントをトリガーする場合、イベントオブジェクトを生成しないため、イベントオブジェクトに対する付加情報を渡すことができない。

上の図を見れば分かるが、この方式は重なっている時だけでなく、遠く離れた要素に対してもイベントをトリガーできるようにしてくれる。
また、ref.click()は引数を受け取らない。
これはイベントオブジェクトを渡すことができないということであり、イベント発生時点の情報を渡すことができないという意味にもなる。
実際にこれに関連して出力してみると次のように出る。

上を見るとclickX、clickYなど様々な情報が全部0で埋められているのを見ることができる。

上で見られるように引数が0であることが分かる。
🧑💻 3次試行(成功):Custom Eventを通じたイベント伝達
BoostCampの過程の中でEventEmitterに関連した内容を学習したことがあった。
Node.js環境でマルチスレッドを扱う方法を学習したが、この過程の中でEventEmitterを使用した。
この時にカスタムイベントを発生させる方法を学習したので、これがふと思い浮かんで適用してみることにした。
方式は簡単だ。
new Event('click')のようにイベントオブジェクトを生成し、これをdispatchEvent()でキャッチする方式だ。
これに対する構造は以下のとおりだ。

既存に描いたものとは大きな違いはないが、方法はイベントを直接発生させるよりはカスタムイベントを定義してこれに基づいて伝達することだ。
このようにした場合の利点は以下のとおりだ。
"click"のように基本イベントを生成して伝達すると、click()と同じ効果を引き起こすことができる。つまり、基本イベントの使用が可能だ。- Naver地図や、様々な地図が作ったカスタムイベントをそのまま使用できる。
これに対する原理は以下のとおりだ。
HTML標準方式を使用するので、これを使用するためにはrefが必要だった。
すでに上でrefを使用したので、これを活用することにした。
これのためのコードは以下のとおりだ。
このようにすると、MouseEventを通じてイベントを生成し、dispatchEventを通じてイベントを発生させることができるようになる。
実際に作成したコードだが、このようにイベントオブジェクトを生成してdispatchEventで受け取る形式だ。
参考に調べてみると、ref.current.click()とdispatchEventの違いは以下のとおりだった。
| 特性 | ref.current.click() | dispatchEvent()方式 |
|---|---|---|
| イベント伝播 | なし(バブリングX) | バブリング可能(bubbles: true) |
| React SyntheticEvent動作 | なし | 動作 |
| 基本クリック動作実行 | 実行される(<a>のリンククリックなど) | 実行されない |
| 使用目的 | 簡単なDOMクリックトリガー | カスタマイズされたイベント処理 |
🤔 コールスタックエラー発生
うまく動作すると思ったが、次のようなエラーが発生した。

エラーメッセージを解釈してみると、コールスタックがオーバーフローした...そのようなイシューだった。(Macを使っていて、ブラウザでもこれに関して最適化されていて非常に久しぶりに見たイシューなので新鮮だった。)
明らかにコードロジックには問題がないのに何でだろう...?と思って見てみると、次のような設定がされていた。
イベントキャプチャリング/バブリングに関連してbubbles属性をtrueに設定していたが、これが問題だった。

ここまでして、イベントに関しては本来の意図どおり同時に発生させることができ、座標伝達もできた。

上記のようにイベントが正常に動作することが分かる。
これで意図どおりにイベントを発生させることができるようになった。
🧑💻 追加の考慮事項:Naver地図は自体的にイベントを処理する。
この例のように、私はHTMLElement単位で考えていた。
前でNaver地図など色々を考慮するとは言ったが、地図に実際に何かを被せて動作させてみてはいなかった。
上記のリンクを見れば分かるが、Naver地図は移動については自体的な機能を提供していた。

先に言及したように、開発者ツールを通じてNaver地図を見た時、Naver地図は一つのタイル単位で複数の画像を合わせて一つの画面を見せる方式で実装されているので、これを同時に処理するためにこのように実装したのではないかと思った。

クリックしたオプションに対する部分を見ても、absoluteやz-indexのような属性が見え、一つのレイヤーの上で動作するのではないかと思った。
また、Naver地図は前で確認したようにすべての地図をローディングするのではなく必要な部分だけローディングする方式なので...このような部分に対する処理のためにも自体的にイベントハンドラなどを全部実装したのではないかと思った。
上記の例から見られるように、移動に対する部分などはpanTo、panByのような要素を使えばよさそうだった。
🧑💻 最後の問題:キャンバスと地図間の微妙な同期差をどうするか?
3次試行と、追加の考慮事項を通じて、これでNaver地図とキャンバスに対する連携を実装できるようになった。
しかし上記のように地図とキャンバスの移動などがきちんと同期されていないのを見ることができた。
数値を合わせて動かしても、上の映像のように地図は毎回再レンダリングが行われるので必然的に差が出るしかなかった。
しばらく途方に暮れていたが...幸いにもキャンバスも移動する時に毎回再レンダリングをしてやることを思い出し、これに従って一定レベル以上移動したら地図とキャンバス両方同時に再レンダリングされる方式を採用した。
ドラッグの場合、イベントがあまりにも頻繁に発生してスロットリングやデバウンシングを適用する必要があった。
これに従って最終的に作成したコードは以下のとおりだ。
🚀 まとめ
本当に長い文章だったと思う。
最初に書こうと思った時はこんなに長くなるとは思わなかったが...整理もあまりできていないようで...
継続的に推敲しなければならないようだ。
今回の文章ではキャンバスと地図を連携する方法をイベントの観点から見てきた。
実際にプロジェクトを進行した過程であり、短く書いたが過程一つ一つをデバッグして追跡するのに本当に長い時間がかかったようだ。
今回の経験を通じてイベント自体に対する理解度を高めることができ、これを通じて他のイベントについてもより早く解決できるようになったと思う。
余談を少し付け加えると...どんなイベント状況を与えても扱えるようだという考えがするほど...本当に苦労も沢山して探求も沢山した。
わざと私が経験したことを詳細に書いて...似たような問題を経験した人がいるなら、文章の一部分を通じてでも助けになればと思う。
📚 整理
click()などのトリガーを通じて発生させる方式はイベントオブジェクトを渡すことができない。MouseEventを通じてイベントを生成し、dispatchEventでイベントを発生させるのが最も汎用的な方法のようだ。- 同時に同じイベントを発生させて、同じ視覚的効果を出すことは単純にイベント一つでできる問題ではない。最後の考慮事項として同期差を考慮したように、状況に合わせて追加的な要素を考慮する必要がある。
- つまり、銀の弾丸はない。問題に合った適切な解決策を見つける必要がある。
- この文章がそのような問題を探していく時に助けになれば幸いだ。
長文を読んでいただきありがとうございます。🙇