Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第8回です。前回(第7回)では、Places API (New) で施設名を検索し、選んだ施設の位置に地点を登録できるようにしました。今回は、カテゴリのチェックボックスで表示する地点を絞り込み、左側に地点の一覧を出して、一覧と地図を連動させます。一覧の行を選ぶと地図がその地点へ移動してピンが大きくなり、地図のピンを選ぶと一覧の該当行が強調されてスクロールします。
「地図だけだと、ピンが多いとどこに何があるか探しにくい。一覧もあると助かるけど、作るのは大変?」
結論から言うと、ここまでの回で「選択中の地点」を1か所で管理する形にしてあるので、一覧を足す作業自体は小さく済みます。今回の実装はすべて画面の中の処理で、Google の追加のAPI費用もかかりません。工数を左右するのは技術よりも、「一覧に何を出すか」「どの順に並べるか」「何で絞り込むか」といった業務の要件をどう決めるかです。
この回で作るもの:絞り込みと一覧の連動
| 操作 | 画面の動き |
|---|---|
| カテゴリのチェックを外す | そのカテゴリのピンと一覧の行が同時に消える。「表示 N / 全 M 件」も更新 |
| 一覧の行をクリック | 地図がその地点へ移動し、ピンが大きく表示され、InfoWindow と詳細パネルが開く |
| 地図のピンをクリック | 一覧の該当行が強調され、見える位置までスクロールする |
| 選択中の地点のカテゴリを非表示にする | 選択が解除される |
画面は「一覧(左)・地図(中央)・詳細(右)」の3列です。スマートフォンなど幅の狭い画面では、地図・詳細・一覧の順に縦に並べます。
仕組み:状態は2つだけ、あとは計算で出す
画面全体で共有する状態は、次の2つだけです。
| 状態 | 置き場所 | 役割 |
|---|---|---|
| 選択中の地点ID | useSelection(第4回) | 地図・一覧・詳細パネル・InfoWindow が同じ地点を指す |
| 表示するカテゴリの配列 | usePointFilter(今回) | 地図のピンと一覧が同じ絞り込み結果を使う |
絞り込んだ結果の地点リストは、状態として持たずに computed(元のデータから自動で計算される値)で作ります。元のデータ・表示カテゴリのどちらが変わっても結果が自動で更新されるので、「地図は絞り込まれたのに一覧は古いまま」というずれが起きません。
📰 出典:Vue.js「算出プロパティ」
実装
| ファイル | 役割 |
|---|---|
app/composables/usePointFilter.ts | 表示カテゴリの共有と、絞り込み結果・カテゴリ別件数の計算(新規) |
app/components/CategoryFilter.vue | カテゴリのチェックボックス。第2回の凡例(CategoryLegend.vue)を置き換え(新規) |
app/components/PointList.vue | 地点の一覧(新規) |
app/composables/useMarkers.ts | 選択中のピンを大きく表示(更新) |
app/pages/index.vue | 3列の配置と連動の処理(更新) |
手順1:絞り込みの composable
app/composables/usePointFilter.ts
import { CATEGORIES, type Category, type Point } from '#shared/types/point'
export function usePointFilter(points: Ref<Point[]>) {
const visibleCategories = useState<Category[]>('visible-categories', () => [...CATEGORIES])
const filteredPoints = computed(() =>
points.value.filter((p) => visibleCategories.value.includes(p.category)),
)
// カテゴリごとの件数(絞り込み前)。チェックボックスの横に出す
const countByCategory = computed(() => {
const counts = Object.fromEntries(CATEGORIES.map((c) => [c, 0])) as Record<Category, number>
for (const p of points.value) counts[p.category]++
return counts
})
return { visibleCategories, filteredPoints, countByCategory }
}
表示カテゴリは useState で持ちます。初期値は全カテゴリです。第9回で地点の読み込み方を「地図の表示範囲だけ取る」形に変えても、絞り込みはこの composable の中で完結しているので影響を受けません。
手順2:カテゴリのチェックボックス
app/components/CategoryFilter.vue
<script setup lang="ts">
import { CATEGORIES, CATEGORY_META, type Category } from '#shared/types/point'
defineProps<{ counts: Record<Category, number>; shown: number; total: number }>()
// v-model で表示中のカテゴリ配列を受け渡す
const visible = defineModel<Category[]>({ required: true })
</script>
<template>
<fieldset class="category-filter">
<legend class="visually-hidden">表示するカテゴリ</legend>
<label v-for="c in CATEGORIES" :key="c" class="category-filter__item">
<input v-model="visible" type="checkbox" :value="c">
<span class="legend__dot" :style="{ background: CATEGORY_META[c].color }" aria-hidden="true">
{{ CATEGORY_META[c].glyph }}
</span>
{{ CATEGORY_META[c].label }}({{ counts[c] }})
</label>
<span class="category-filter__count">表示 {{ shown }} / 全 {{ total }} 件</span>
</fieldset>
</template>
Vue では、複数のチェックボックスに同じ配列を v-model でつなぐと、チェックされた value の配列として扱えます。defineModel を使うと、親の v-model="visibleCategories" とこの配列がそのまま結びつきます。
第2回で作った凡例は、ピンの色見本を兼ねたこのチェックボックスに置き換えました。チェックボックス全体を fieldset で囲み、読み上げソフト向けに見えない見出し(legend)を付けています。
手順3:地点の一覧
app/components/PointList.vue(スクリプト部分)
const props = defineProps<{ points: Point[]; selectedId: number | null }>()
const emit = defineEmits<{ select: [id: number] }>()
const rowEls = new Map<number, HTMLElement>()
function setRowEl(id: number, el: unknown) {
if (el instanceof HTMLElement) rowEls.set(id, el)
else rowEls.delete(id)
}
// 地図側で選択が変わったら、一覧の該当行が見える位置までスクロールする
watch(
() => props.selectedId,
async (id) => {
if (id === null) return
await nextTick()
rowEls.get(id)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
},
)
テンプレートでは、行ごとに <button> を置き、選択中の行には強調用のクラスと aria-current="true" を付けています。行を <button> にしておくと、Tab キーで移動して Enter で選ぶ操作が特別な実装なしで使えます。
各行の要素は、関数形式の ref(:ref="(el) => setRowEl(p.id, el)")で ID ごとに控えておき、選択が変わったら scrollIntoView({ block: 'nearest' }) で該当行を表示します。nearest を指定すると、すでに見えている行ではスクロールが起きないので、一覧から選んだときに表示が無駄に動きません。
手順4:選択中のピンを強調する
app/composables/useMarkers.ts(追加部分)
// 第8回: 選択中のピンだけ大きく・手前に表示する
function highlight(id: number | null) {
if (highlightedId !== null) {
const prevPin = pins.get(highlightedId)
const prevMarker = markers.get(highlightedId)
if (prevPin) prevPin.scale = 1
if (prevMarker) prevMarker.zIndex = null
}
highlightedId = id
if (id === null) return
const pin = pins.get(id)
const marker = markers.get(id)
if (pin) pin.scale = 1.4
if (marker) marker.zIndex = 999
}
function onSelectionChange(id: number | null) {
highlight(id)
showInfoWindow(id)
}
// 選択が変わったら(地図・一覧・詳細パネルのどこから変えても)強調と InfoWindow を追従させる
watch(selectedId, onSelectionChange)
PinElement の scale と、マーカーの zIndex(重なり順)を書き換えるだけで、ピンを作り直さずに強調できます。第4回と同じく、きっかけは「選択中の地点IDが変わったこと」だけです。一覧から選んでも地図から選んでも同じ経路で動くので、処理が二重になりません。
📰 出典:Google Maps Platform「Basic marker customization」
手順5:ページで組み合わせる
app/pages/index.vue(追加・変更部分)
const { visibleCategories, filteredPoints, countByCategory } = usePointFilter(points)
// 一覧の行を選ぶ → 選択して、そのピンが地図の中央に来るよう移動する
function onListSelect(id: number) {
select(id)
const p = points.value.find((x) => x.id === id)
if (p && map.value) map.value.panTo({ lat: p.lat, lng: p.lng })
}
// 絞り込みで選択中の地点が見えなくなったら、選択を解除する
watch(filteredPoints, (list) => {
if (selectedId.value !== null && !list.some((p) => p.id === selectedId.value)) {
clearSelection()
}
})
// 地図の準備ができた時点と、データ・絞り込みが変わった時点でピンを描き直す
watch([map, filteredPoints], ([m, list]) => {
if (m) render(m, list)
})
<CategoryFilter
v-model="visibleCategories"
:counts="countByCategory"
:shown="filteredPoints.length"
:total="points.length"
/>
<!-- … -->
<div class="map-page__body">
<PointList :points="filteredPoints" :selected-id="selectedId" @select="onListSelect" />
<!-- 地図(ClientOnly)と、登録フォーム/詳細パネルは前回と同じ -->
</div>
ピンを描き直す watch の対象を、全件の points から絞り込み後の filteredPoints に変えました。地図の移動(panTo)は一覧から選んだときだけにしています。地図上のピンをクリックしたときは、利用者はすでにその場所を見ているので、地図を動かさないほうが自然です。
もう1つ、第5回の登録処理にも手を入れました。非表示にしているカテゴリで地点を登録すると、保存後にその地点が見えなくなってしまうため、保存したカテゴリを自動で表示に戻しています。
一覧と絞り込みは <ClientOnly> の外に置いているので、サーバー側で描画した最初のHTMLにも一覧が入ります。地図の読み込みを待たずに一覧を読める点も、業務で使う画面では地味に効いてきます。
動作確認の方法
npm run dev→ http://localhost:3000 を開く。カテゴリごとの件数と「表示 N / 全 M 件」、左の一覧が出ることを確認- 「店舗」のチェックを外す → 店舗のピンと一覧の行が同時に消え、件数が減ることを確認
- 一覧の行をクリック → 地図がその地点へ移動し、ピンが大きくなり、InfoWindow と詳細パネルが開くことを確認
- 地図で別のピンをクリック → 一覧の該当行が強調され、見える位置までスクロールすることを確認
- 選択中の地点のカテゴリのチェックを外す → 選択が解除されることを確認
筆者の環境では、npm run build と npm run typecheck の成功に加え、ビルド後のサーバーが返すHTMLで、カテゴリごとの件数・全カテゴリにチェックが入った初期状態・「表示 23 / 全 23 件」・一覧の全行が出力されていることを curl で確認しました(件数は検証用に登録した地点を含みます)。チェックボックスの操作による絞り込み、一覧と地図の連動、ピンの強調表示は、執筆環境に本物のAPIキーとブラウザでの操作環境がないため確認できていません。 ご自身のキーを設定して確認してください。
つまずきやすい点・セキュリティ上の注意
- 絞り込み結果を別の状態としてコピーして持つ:元データの更新と同期が取れなくなります。
computedで計算します - 一覧と地図で別々に選択状態を持つ:第4回のとおり、選択中のIDは1か所だけにします
- 絞り込みのたびに全ピンを作り直している:今は20件程度なので問題ありませんが、数千件では重くなります。次回、差分だけ更新する形に変えます
- 非表示の地点が選択されたまま残る:詳細パネルに見えない地点が出続けるので、絞り込み後に選択を確認して解除します
- 一覧の行を
divとクリックイベントだけで作る:キーボードで操作できなくなります。buttonを使うと標準で対応できます
発注者向けメモ
- 絞り込みや一覧の連動は画面内の処理で、Google の追加API費用は発生しない構造です。費用ではなく、使い勝手と工数のバランスで仕様を決められる部分です
- 工数を最も左右するのは要件の確定です。一覧に出す項目(名称だけか、住所・担当者・更新日も出すか)、並び順(登録順・名前順・近い順)、絞り込み条件(カテゴリだけか、担当者・期間・状態も必要か)によって作業量が大きく変わります。現場の担当者に「普段どうやって探しているか」を聞いてから決めるのがおすすめです
- 絞り込み条件を URL に残すかも確認しておきたい点です。「この条件の画面を同僚に送りたい」という要望は後から出やすく、最初から考えておくと手戻りが減ります(本連載では未対応)
- スマホでの画面構成は別途確認が必要です。3列の画面は狭い画面ではそのまま使えないため、どの情報を優先して見せるかを決めておきましょう
発注者がやることのチェックリストです。
- ☐ 一覧に表示する項目と並び順を決めた
- ☐ 絞り込みに使う条件(カテゴリ以外も含めて)を洗い出した
- ☐ スマホで使う場面と、優先して見せる情報を決めた
- ☐ 将来の地点数の見込み(今と3年後)を伝えた
開発会社への確認に使える質問例です。
- 「絞り込みや一覧の表示で、Google のAPI費用が増える操作はありますか?」
- 「地点が数千件になったとき、一覧と地図の表示速度はどうなりますか?」
- 「一覧や絞り込みは、キーボードや読み上げソフトでも操作できますか?」
まとめと次回予告
この回では、カテゴリのチェックボックスで地点を絞り込み、左の一覧と地図のピンを連動させました。共有する状態は「選択中のID」と「表示カテゴリ」の2つだけにし、絞り込み結果は computed で計算する、強調やスクロールは選択の変化をきっかけに1か所で行う、の2点が押さえどころです。
次回は「マーカーが数千件でも重くしない:クラスタリングと表示範囲読み込み」です。近くのピンをまとめて表示するクラスタリングと、地図に見えている範囲の地点だけをサーバーから取得する仕組みで、地点が大量にあっても快適に動くようにします。
この連載の記事一覧
この記事は連載「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キー制限・クォータ・予算アラート・性能










コメント
コメント一覧 (2件)
[…] Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第9回です。前回(第8回)では、カテゴリで地点を絞り込み、左の一覧と地図のピンを連動させました。今回は、地点が数千件に増えても地図が重くならないように、近くのピンを「◯件」の丸にまとめるクラスタリングと、地図に見えている範囲の地点だけをサーバーから読み込む仕組みを入れます。 […]
[…] カテゴリで絞り込み、一覧と地図を連動させる […]