MENU

問い合わせ


    【NuxtとGoogle Mapsで作る情報集約マップ 第4回】マーカーをクリックしてInfoWindowと詳細パネルを出す

    Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第4回です。前回(第3回)では、地点データを Nuxt のサーバーAPIとSQLiteに保存し、画面はAPIから読み込む形にしました。今回は、地図のピン(マーカー)をクリックすると、吹き出し(InfoWindow)に概要、右側の詳細パネルにメモや更新日時が出るようにします。

    「ピンをクリックしたら情報が出る、というのは地図アプリなら当たり前に見える。どこに手間がかかるの?」

    結論から言うと、表示すること自体はGoogle Maps の標準機能で簡単にできます。手間がかかるのは、「今どの地点を選んでいるか」を画面全体で1つに保つことと、キーボード操作や入力データの安全な表示といった細部です。この回では選択状態を useState で共有し、地図・吹き出し・詳細パネルが同じ地点を指すようにします。

    目次

    この回で作るもの:ピンの選択と詳細表示

    操作画面の動き
    ピンをクリック(またはキーボードで選んで Enter)ピンの上に InfoWindow(名称・カテゴリ・メモの冒頭)、右のパネルに詳細
    別のピンをクリックInfoWindow と詳細パネルが同時に切り替わる
    InfoWindow の×、または詳細パネルの×両方が閉じて「未選択」に戻る

    スマホなど画面幅が狭い場合は、詳細パネルが地図の下に回り込むようにしています。

    仕組み:選択状態を1か所で持つ

    選択状態を地図側とパネル側でそれぞれ持つと、「地図では A を選んでいるのにパネルは B のまま」といったずれが起きます。そこで、選択中の地点IDを1つだけ useState で持ち、地図もパネルもそれを見る形にしました。

    部品役割
    useSelection(新規)選択中の地点ID(selectedId)を画面全体で共有
    useMarkers(更新)ピンのクリックで selectedId を更新し、selectedId の変化で InfoWindow を開閉
    PointDetail.vue(新規)selectedId に対応する地点の詳細を表示。×で選択解除

    useState は Nuxt の機能で、同じキーで呼べばどのコンポーネントからでも同じ値を参照できます。第8回で地点の一覧を追加するときも、一覧側はこの selectedId を読み書きするだけで地図と連動させられます。

    📰 出典:Nuxt「useState」

    実装

    この回で追加・変更したファイルは次のとおりです。

    ファイル役割
    app/composables/useSelection.ts選択状態の共有(新規)
    app/composables/useMarkers.tsクリック対応と InfoWindow(更新)
    app/components/PointDetail.vue詳細パネル(新規)
    app/pages/index.vue地図と詳細パネルの配置(更新)

    手順1:選択状態を共有する composable

    app/composables/useSelection.ts

    export function useSelection() {
      const selectedId = useState<number | null>('selected-point-id', () => null)
    
      function select(id: number) {
        selectedId.value = id
      }
    
      function clearSelection() {
        selectedId.value = null
      }
    
      return { selectedId, select, clearSelection }
    }

    持つのは地点のID だけです。地点データそのものを複製して持つと、データを更新したときに古い内容が残る原因になるためです。表示に必要な地点データは、ページ側で ID から探します。

    手順2:ピンをクリックできるようにする

    Advanced Markers は、初期状態ではクリックできません。gmpClickable: true を指定し、gmp-click イベントで選択します。

    📰 出典:Google Maps Platform「Make markers clickable and accessible」

    app/composables/useMarkers.ts(マーカー作成部分の抜粋)

          const marker = new AdvancedMarkerElement({
            map,
            position: { lat: point.lat, lng: point.lng },
            title: `${meta.label}: ${point.name}`, // ツールチップ兼スクリーンリーダー用
            gmpClickable: true, // クリック・キーボード操作を有効にする
          })
          marker.append(pin)
          marker.addEventListener('gmp-click', () => select(point.id))
          markers.set(point.id, marker)
          pointsById.set(point.id, point)

    第2回で触れたとおり、Advanced Markers のイベントは標準の addEventListener で受け取ります(旧来の addListener('click', …) は型定義上で非推奨)。公式ドキュメントによると、gmpClickable を有効にしたマーカーは、Tab キーで地図内のマーカーにフォーカスし、矢印キーでマーカー間を移動、Enter またはスペースで選択できます。マウスを使わない利用者も操作できるようになるので、クリックできるマーカーには必ず title も付けておきます。

    手順3:InfoWindow を選択状態に追従させる

    app/composables/useMarkers.ts(InfoWindow 部分の抜粋)

        if (!infoWindow) {
          infoWindow = new InfoWindow({ maxWidth: 280 })
          // ×ボタンで閉じたら選択も解除する(close() による close イベントとは区別)
          infoWindow.addListener('closeclick', () => clearSelection())
        }
      function showInfoWindow(id: number | null) {
        if (!infoWindow) return
        const marker = id === null ? undefined : markers.get(id)
        const point = id === null ? undefined : pointsById.get(id)
        if (!marker || !point || !currentMap) {
          infoWindow.close()
          return
        }
        infoWindow.setHeaderContent(point.name) // 文字列はプレーンテキストとして表示される
        infoWindow.setContent(buildSummary(point))
        infoWindow.open({ map: currentMap, anchor: marker })
      }
    
      // 選択が変わったら(地図・詳細パネルのどちらから変えても)InfoWindow を追従させる
      watch(selectedId, showInfoWindow)

    📰 出典:Google Maps Platform「Maps JavaScript API Reference – Info Window」

    InfoWindow は地図に1つだけ作り、選択が変わるたびに中身を差し替えて開き直します。ピンごとに InfoWindow を作ると、複数の吹き出しが同時に開いて「どれを選んでいるか」が分かりにくくなるためです。

    ポイントは「ピンのクリック → InfoWindow を開く」と直接つながないことです。ピンのクリックは selectedId を変えるだけにし、InfoWindow は selectedId の変化を watch して開閉します。こうしておくと、詳細パネルの×や、第8回の一覧からの選択でも、同じ経路で InfoWindow が動きます。

    InfoWindow のイベントには、閉じたとき全般に発生する close と、×ボタンが押されたときだけ発生する closeclick があります。ここでは closeclick を使っています。close を使うと、プログラムから close() を呼んだときにも選択解除が走り、別のピンに切り替えた際の処理と干渉しやすいためです。

    手順4:利用者の入力は textContent で表示する

    app/composables/useMarkers.ts(buildSummary)

    // InfoWindow の中身。利用者が入力した文字列を含むので innerHTML は使わず textContent で組み立てる
    function buildSummary(point: Point): HTMLElement {
      const root = document.createElement('div')
      root.className = 'iw-summary'
    
      const category = document.createElement('span')
      category.className = 'iw-summary__category'
      category.textContent = CATEGORY_META[point.category].label
      category.style.background = CATEGORY_META[point.category].color
      root.append(category)
    
      if (point.memo) {
        const memo = document.createElement('p')
        memo.textContent = point.memo.length > 60 ? `${point.memo.slice(0, 60)}…` : point.memo
        root.append(memo)
      }
      return root
    }

    InfoWindow の setContent() に文字列を渡すと HTML として解釈されます。地点名やメモは社内の利用者が自由に入力できる値なので、文字列をつなげて HTML を作ると、入力内容によっては意図しないスクリプトが実行される脆弱性(XSS=クロスサイトスクリプティング)につながります。ここでは HTML 要素を作って textContent で文字を入れることで、入力値が常にただの文字として表示されるようにしています。ヘッダー(setHeaderContent)は、公式リファレンスで文字列がプレーンテキストとして扱われると説明されているため、名称をそのまま渡しています。

    手順5:詳細パネル

    app/components/PointDetail.vue(抜粋)

    <script setup lang="ts">
    import { CATEGORY_META, type Point } from '#shared/types/point'
    
    const props = defineProps<{ point: Point | null }>()
    const emit = defineEmits<{ close: [] }>()
    
    // 日時は日本時間で表示(サーバー描画とブラウザ描画で表記がずれないようタイムゾーンを固定)
    const dateFormat = new Intl.DateTimeFormat('ja-JP', {
      dateStyle: 'medium',
      timeStyle: 'short',
      timeZone: 'Asia/Tokyo',
    })
    const updatedAt = computed(() =>
      props.point ? dateFormat.format(new Date(props.point.updatedAt)) : '',
    )
    </script>

    テンプレートでは、名称・カテゴリ・メモ・緯度経度・更新日時を表示し、×ボタンで close イベントを出します。メモは {{ point.memo }} で表示しており、Vue のテキスト展開は自動でエスケープされるので安全です(v-html は使いません)。パネル全体には aria-live="polite" を付け、スクリーンリーダーで内容の切り替わりが伝わるようにしています。

    更新日時の表示で timeZone: 'Asia/Tokyo' を指定しているのは、Nuxt ではサーバー側とブラウザ側の両方で画面を描くためです。サーバーのタイムゾーンが日本時間でない環境(クラウドのサーバーなど)では、表示がずれて警告の原因になります。

    手順6:ページで組み合わせる

    app/pages/index.vue(抜粋)

    <script setup lang="ts">
    const { data: points } = await useFetch<Point[]>('/api/points', { default: () => [] })
    const { render } = useMarkers()
    const { selectedId, clearSelection } = useSelection()
    
    const selectedPoint = computed(
      () => points.value.find((p) => p.id === selectedId.value) ?? null,
    )
    // …(地図の準備とデータ取得を watch してピンを描く部分は第3回と同じ)
    </script>
    
    <template>
      <div class="map-page">
        <CategoryLegend :count="points.length" />
        <div class="map-page__body">
          <ClientOnly>
            <MapView @ready="onMapReady" />
            <template #fallback>
              <div class="map-placeholder">地図を読み込み中…</div>
            </template>
          </ClientOnly>
          <PointDetail :point="selectedPoint" @close="clearSelection" />
        </div>
      </div>
    </template>

    詳細パネルは <ClientOnly> の外に置いています。地図と違ってブラウザ専用の処理を含まないので、サーバー側でも描画できます。

    動作確認の方法

    1. npm run dev → http://localhost:3000 を開く。右側に「地図のピンを選ぶと、ここに詳細が表示されます。」と出ていることを確認
    2. ピンをクリック → InfoWindow と詳細パネルに同じ地点の名称が出ることを確認
    3. 別のピンをクリック → 両方が切り替わることを確認
    4. InfoWindow の×、または詳細パネルの× → 両方が閉じることを確認
    5. 地図をクリックしてから Tab キーでマーカーにフォーカスし、矢印キーで移動、Enter で選択できることを確認
    6. 第3回の curl で "memo":"<b>test</b>" のような地点を登録し、タグがそのまま文字として表示されることを確認

    筆者の環境では、npm run build と npm run typecheck の成功、ビルド後のサーバーが返すHTMLに詳細パネルの初期表示(未選択時の案内文)と件数が入ること、API が引き続き正常に応答することを確認しました。ピンのクリック、InfoWindow の表示、キーボード操作は、執筆環境に本物のAPIキーがないため確認できていません。 ご自身のキーを .env に設定して確認してください。

    つまずきやすい点・セキュリティ上の注意

    • gmpClickable を付け忘れる:付けないと gmp-click が発生しません
    • InfoWindow に文字列の HTML を渡す:利用者の入力を含む場合は、要素を作って textContent で入れます。社内システムでも、入力者と閲覧者が別の人である以上、対策は必要です
    • InfoWindow をピンごとに作る:地図に1つだけ作り、中身を差し替えるほうが状態の管理が簡単です
    • 選択中の地点が削除された場合:地点データから見つからなければ InfoWindow を閉じ、パネルも未選択表示に戻るようにしています(find の結果が null になるため)

    発注者向けメモ

    • ピンのクリックや InfoWindow の表示で、追加のAPI費用は発生しない構造です。地図を読み込んだ後の画面内の操作だからです。費用に効いてくるのは、第6回以降の住所変換や施設検索など、Google のサーバーに問い合わせる操作です
    • 工数を左右するのは「詳細の見せ方」の要件です。スマホで詳細を下に出すか全画面で出すか、詳細に写真や添付ファイルを載せるか、パネルから直接編集できるようにするか、で作業量が大きく変わります。画面のラフ(手書きでも可)を早めに共有すると、見積りのぶれが小さくなります
    • キーボード操作・読み上げへの対応を要件に入れるかも決めておきましょう。Advanced Markers には基本的な仕組みがありますが、パネルの操作やフォーカスの移し方まで含めると追加の確認・調整が必要です
    • 入力データの安全な表示(XSS対策)は、「当然やるもの」として見積りに含まれているかを確認します

    開発会社への確認に使える質問例です。

    • 「スマートフォンで地点の詳細を見るときは、どのような画面になりますか?」
    • 「地図のピンはキーボードでも選択できますか?読み上げソフトではどう読まれますか?」
    • 「利用者が入力したメモを表示する部分で、XSS 対策はどのように行っていますか?」

    まとめと次回予告

    この回では、ピンのクリックで InfoWindow と詳細パネルに同じ地点の情報を出しました。選択中の地点IDを useState で1か所に持つ、InfoWindow はその変化に追従させる、利用者の入力は textContent や Vue のテキスト展開で表示する、の3点が押さえどころです。

    次回は「地図をクリックして新しい地点を登録する」です。地図上をクリックして仮のピンを立て、フォームに名称やカテゴリを入れて保存する機能を作ります。ピンをドラッグして位置を微調整する方法も扱います。

    この連載の記事一覧

    この記事は連載「NuxtとGoogle Mapsで作る情報集約マップ」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (3件)

      目次