MENU

問い合わせ


    【NuxtとGoogle Mapsで作る情報集約マップ 第8回】カテゴリで絞り込み、一覧と地図を連動させる

    Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第8回です。前回(第7回)では、Places API (New) で施設名を検索し、選んだ施設の位置に地点を登録できるようにしました。今回は、カテゴリのチェックボックスで表示する地点を絞り込み、左側に地点の一覧を出して、一覧と地図を連動させます。一覧の行を選ぶと地図がその地点へ移動してピンが大きくなり、地図のピンを選ぶと一覧の該当行が強調されてスクロールします。

    「地図だけだと、ピンが多いとどこに何があるか探しにくい。一覧もあると助かるけど、作るのは大変?」

    結論から言うと、ここまでの回で「選択中の地点」を1か所で管理する形にしてあるので、一覧を足す作業自体は小さく済みます。今回の実装はすべて画面の中の処理で、Google の追加のAPI費用もかかりません。工数を左右するのは技術よりも、「一覧に何を出すか」「どの順に並べるか」「何で絞り込むか」といった業務の要件をどう決めるかです。

    目次

    この回で作るもの:絞り込みと一覧の連動

    操作画面の動き
    カテゴリのチェックを外すそのカテゴリのピンと一覧の行が同時に消える。「表示 N / 全 M 件」も更新
    一覧の行をクリック地図がその地点へ移動し、ピンが大きく表示され、InfoWindow と詳細パネルが開く
    地図のピンをクリック一覧の該当行が強調され、見える位置までスクロールする
    選択中の地点のカテゴリを非表示にする選択が解除される

    画面は「一覧(左)・地図(中央)・詳細(右)」の3列です。スマートフォンなど幅の狭い画面では、地図・詳細・一覧の順に縦に並べます。

    仕組み:状態は2つだけ、あとは計算で出す

    画面全体で共有する状態は、次の2つだけです。

    状態置き場所役割
    選択中の地点IDuseSelection(第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.vue3列の配置と連動の処理(更新)

    手順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" とこの配列がそのまま結びつきます。

    📰 出典:Vue.js「コンポーネントの v-model」

    第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にも一覧が入ります。地図の読み込みを待たずに一覧を読める点も、業務で使う画面では地味に効いてきます。

    動作確認の方法

    1. npm run dev → http://localhost:3000 を開く。カテゴリごとの件数と「表示 N / 全 M 件」、左の一覧が出ることを確認
    2. 「店舗」のチェックを外す → 店舗のピンと一覧の行が同時に消え、件数が減ることを確認
    3. 一覧の行をクリック → 地図がその地点へ移動し、ピンが大きくなり、InfoWindow と詳細パネルが開くことを確認
    4. 地図で別のピンをクリック → 一覧の該当行が強調され、見える位置までスクロールすることを確認
    5. 選択中の地点のカテゴリのチェックを外す → 選択が解除されることを確認

    筆者の環境では、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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (2件)

      目次