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> の外に置いています。地図と違ってブラウザ専用の処理を含まないので、サーバー側でも描画できます。
動作確認の方法
npm run dev→ http://localhost:3000 を開く。右側に「地図のピンを選ぶと、ここに詳細が表示されます。」と出ていることを確認- ピンをクリック → InfoWindow と詳細パネルに同じ地点の名称が出ることを確認
- 別のピンをクリック → 両方が切り替わることを確認
- InfoWindow の×、または詳細パネルの× → 両方が閉じることを確認
- 地図をクリックしてから Tab キーでマーカーにフォーカスし、矢印キーで移動、Enter で選択できることを確認
- 第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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【NuxtとGoogle Mapsで作る情報集約マップ 第1回】APIキーとMap IDを用意して、Nuxt 4で地図を1枚表示する
- 【NuxtとGoogle Mapsで作る情報集約マップ 第2回】Advanced Markersでカテゴリ別の色付きピンを立てる
- 【NuxtとGoogle Mapsで作る情報集約マップ 第3回】地点データをNuxt server API + SQLiteに保存する
- 【NuxtとGoogle Mapsで作る情報集約マップ 第4回】マーカーをクリックしてInfoWindowと詳細パネルを出す(この記事)
- 【NuxtとGoogle Mapsで作る情報集約マップ 第5回】地図をクリックして新しい地点を登録する
- 【NuxtとGoogle Mapsで作る情報集約マップ 第6回】住所を入力して緯度経度に変換する(Geocoding API v4をサーバー経由で)
- 【NuxtとGoogle Mapsで作る情報集約マップ 第7回】施設名で検索して地点を取り込む(Places API (New))
- 【NuxtとGoogle Mapsで作る情報集約マップ 第8回】カテゴリで絞り込み、一覧と地図を連動させる
- 【NuxtとGoogle Mapsで作る情報集約マップ 第9回】マーカーが数千件でも重くしない:クラスタリングと表示範囲読み込み
- 【NuxtとGoogle Mapsで作る情報集約マップ 第10回】現在地から半径◯km/描いたエリア内の地点を探す
- 【NuxtとGoogle Mapsで作る情報集約マップ 第11回】CSVで既存の台帳を一括取り込みする
- 【NuxtとGoogle Mapsで作る情報集約マップ 第12回】本番公開前チェック:APIキー制限・クォータ・予算アラート・性能










コメント
コメント一覧 (3件)
[…] Maps API とNuxtで社内向けの情報集約マップを作る連載の第5回です。前回(第4回)では、ピンをクリックすると InfoWindow […]
[…] マーカーをクリックしてInfoWindowと詳細パネルを出す […]
[…] 【NuxtとGoogle Mapsで作る情報集約マップ 第4回】マーカーをクリックしてInfoW… […]