MENU

問い合わせ


    【NuxtとGoogle Mapsで作る情報集約マップ 第5回】地図をクリックして新しい地点を登録する

    Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第5回です。前回(第4回)では、ピンをクリックすると InfoWindow と右側の詳細パネルに同じ地点の情報が出るようにしました。今回は、地図の何もない場所をクリックして仮のピンを立て、フォームに名称やカテゴリを入れて保存する機能を作ります。仮のピンはドラッグして位置を微調整できます。

    「地点の登録って、住所を入れるものだと思っていた。地図をクリックして登録する方法にはどんな良さがあるの?」

    結論から言うと、地図クリックでの登録はGoogle の有料APIを呼ばずに済み、座標を自社のデータとして扱いやすい方式です。住所から座標に変換する方法(次回の第6回)は便利ですが、1件ごとに課金され、変換結果の保存にも利用規約上の制限があります。現場の位置を人が地図で確かめて決める運用なら、まずはクリック登録を用意しておくのがおすすめです。

    目次

    この回で作るもの:地図クリックで地点を登録

    操作画面の動き
    地図の空いている場所をクリック赤い枠の「+」ピン(仮ピン)が立ち、右側が登録フォームに切り替わる
    別の場所をクリック仮ピンがその場所へ移動する
    仮ピンをドラッグドラッグを終えた位置の緯度・経度がフォームに反映される
    名称・カテゴリ・メモを入れて「保存する」サーバーAPIで保存 → ピンが描き直され、登録した地点が選択状態になる
    「キャンセル」または既存のピンを選択仮ピンが消え、詳細パネルに戻る

    仕組み:保存済みのピンと仮ピンを分けて管理する

    第2回から使っている useMarkers は、データが変わるたびに保存済みのピンをすべて作り直します。ここに仮ピンを混ぜると、描き直しのたびに仮ピンまで消えてしまいます。そこで、仮ピンは別の composable(useDraftPoint)で1本だけ管理しました。

    部品役割
    MapView.vue(更新)地図のクリック位置を map-click イベントで親に伝える
    useDraftPoint(新規)仮ピンの作成・移動・ドラッグ後の位置の読み取り。位置を useState で共有
    app/utils/latLng.ts(新規)いろいろな形の緯度経度を { lat, lng } にそろえる
    PointForm.vue(新規)名称・カテゴリ・メモの入力と、POST /api/points への保存
    pages/index.vue(更新)仮ピンがあればフォーム、なければ詳細パネルを表示

    サーバー側は第3回で作った POST /api/points をそのまま使います。入力の検証(名称は必須、緯度は -90〜90 など)もサーバーの zod が担当するので、今回の変更は画面側だけです。

    実装

    手順1:地図のクリック位置を親に伝える

    app/components/MapView.vue(変更部分の抜粋)

    const emit = defineEmits<{
      ready: [map: google.maps.Map]
      // 第5回: 地図の何もない場所がクリックされた位置
      'map-click': [position: google.maps.LatLngLiteral]
    }>()
        // 地図クリックの位置は MapMouseEvent.latLng(LatLng)で届く。null の場合もあるので確認する
        map.value.addListener('click', (e: google.maps.MapMouseEvent) => {
          if (e.latLng) emit('map-click', e.latLng.toJSON())
        })

    地図本体(google.maps.Map)のイベントは、従来どおり addListener で受け取ります。公式ドキュメントでも、地図のクリックで event.latLng を使ってマーカーを置く例が紹介されています。型の上では latLng が null の場合もあるため、確認してから使います。toJSON() で { lat, lng } の普通のオブジェクトにしてから親に渡しておくと、この後の扱いが楽になります。

    📰 出典:Google Maps Platform「Events」

    なお、第1回で clickableIcons: false を指定しているので、地図上の店舗や駅のアイコンをクリックしても Google の情報ウィンドウは開かず、通常のクリックとして扱われます。

    手順2:緯度経度の形をそろえる

    app/utils/latLng.ts

    type AnyLatLng =
      | google.maps.LatLng
      | google.maps.LatLngLiteral
      | google.maps.LatLngAltitude
      | google.maps.LatLngAltitudeLiteral
    
    export function toLatLngLiteral(p: AnyLatLng): google.maps.LatLngLiteral {
      const lat = typeof p.lat === 'function' ? p.lat() : p.lat
      const lng = typeof p.lng === 'function' ? p.lng() : p.lng
      return { lat: round6(lat), lng: round6(lng) }
    }
    
    function round6(n: number) {
      return Math.round(n * 1e6) / 1e6
    }

    Advanced Marker の position は、型定義上 LatLng・LatLngLiteral・LatLngAltitude などのどれかになりえます。LatLng は lat() がメソッド、ほかは lat が数値のプロパティなので、そのまま保存しようとすると形がばらつきます。ここで { lat, lng } にそろえ、小数点以下6桁(約10cm単位)に丸めています。地点の登録にはこれで十分な精度です。

    app/utils/ に置いた関数は Nuxt が自動でインポートするので、使う側で import を書く必要はありません。

    手順3:ドラッグできる仮ピン

    app/composables/useDraftPoint.ts(抜粋)

    export function useDraftPoint() {
      const { load } = useGoogleMaps()
      const draftPosition = useState<google.maps.LatLngLiteral | null>('draft-position', () => null)
    
      let marker: google.maps.marker.AdvancedMarkerElement | null = null
    
      async function setDraft(map: google.maps.Map, position: google.maps.LatLngLiteral) {
        const { AdvancedMarkerElement, PinElement } = await load('marker')
        const normalized = toLatLngLiteral(position)
        draftPosition.value = normalized
    
        if (marker) {
          // 2回目以降のクリックは、同じ仮ピンを移動するだけ
          marker.position = normalized
          marker.map = map
          return
        }
    
        const pin = new PinElement({
          background: '#ffffff',
          borderColor: '#cf222e',
          glyphText: '+',
          glyphColor: '#cf222e',
          scale: 1.2,
        })
        marker = new AdvancedMarkerElement({
          map,
          position: normalized,
          title: '登録する位置(ドラッグで調整できます)',
          gmpDraggable: true,
          zIndex: 1000, // 既存のピンより手前に出す
        })
        marker.append(pin)
        // ドラッグを終えた時点の位置を読み取る(position は複数の型のユニオンなので正規化する)
        marker.addEventListener('gmp-dragend', () => {
          if (marker?.position) draftPosition.value = toLatLngLiteral(marker.position)
        })
      }
    
      function clearDraft() {
        draftPosition.value = null
        if (marker) marker.map = null
      }
      // …(onScopeDispose での後片付けは省略)
      return { draftPosition, setDraft, clearDraft }
    }

    Advanced Marker は gmpDraggable: true でドラッグできるようになり、ドラッグの終わりに gmp-dragend イベントが発生します。公式ドキュメントの例と同じく、イベントの後でマーカーの position を読むと新しい位置が取れます。

    📰 出典:Google Maps Platform「Make markers draggable」

    同じページによると、ドラッグ可能なマーカーはキーボードでも動かせます。Tab でマーカーにフォーカスし、Alt+Space(Mac は Option+Space)でドラッグを開始、矢印キーで移動、Space か Enter で確定、Esc で取り消しです。読み上げソフトのために、ドラッグできることが分かる title を付けています。

    仮ピンは白地に赤枠の「+」にして、保存済みのカテゴリ色のピンと見分けがつくようにしました。zIndex を大きくしているのは、既存のピンの近くをクリックしたときに仮ピンが下に隠れないようにするためです。

    手順4:登録フォーム

    app/components/PointForm.vue(スクリプト部分)

    <script setup lang="ts">
    import { CATEGORIES, CATEGORY_META, type Category, type Point } from '#shared/types/point'
    
    const props = defineProps<{ position: google.maps.LatLngLiteral }>()
    const emit = defineEmits<{ saved: [point: Point]; cancel: [] }>()
    
    const name = ref('')
    const category = ref<Category>('store')
    const memo = ref('')
    const saving = ref(false)
    const errorMessage = ref<string | null>(null)
    
    async function submit() {
      errorMessage.value = null
      saving.value = true
      try {
        // サーバー側でも zod で検証される(ブラウザ側のチェックは入力補助にすぎない)
        const point = await $fetch<Point>('/api/points', {
          method: 'POST',
          body: {
            name: name.value,
            category: category.value,
            lat: props.position.lat,
            lng: props.position.lng,
            memo: memo.value,
          },
        })
        emit('saved', point)
      } catch (e) {
        errorMessage.value = toMessage(e)
      } finally {
        saving.value = false
      }
    }
    
    function toMessage(e: unknown): string {
      const status = (e as { statusCode?: number }).statusCode
      if (status === 400) return '入力内容を確認してください(名称は必須・100文字以内です)。'
      return '保存に失敗しました。時間をおいて再度お試しください。'
    }
    </script>

    テンプレートには、名称(required・maxlength="100")、カテゴリの選択、メモ、現在の緯度経度の表示、「キャンセル」「保存する」ボタンを置いています。保存中はボタンを押せないようにして、二重登録を防ぎます。

    ブラウザ側の required や maxlength は、入力ミスをその場で知らせるための補助です。本当の検証はサーバー側の zod が行います。ブラウザのチェックは開発者ツールなどで簡単に回避できるため、サーバーの検証を省かないのが鉄則です。

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

    app/pages/index.vue(抜粋)

    <script setup lang="ts">
    const { data: points, refresh } = await useFetch<Point[]>('/api/points', { default: () => [] })
    const { render } = useMarkers()
    const { selectedId, select, clearSelection } = useSelection()
    const { draftPosition, setDraft, clearDraft } = useDraftPoint()
    
    // 地図の空いている場所をクリック → 選択を解除して仮ピンを立てる
    function onMapClick(position: google.maps.LatLngLiteral) {
      if (!map.value) return
      clearSelection()
      setDraft(map.value, position)
    }
    
    // 既存のピンを選んだら、登録途中の仮ピンは片付ける
    watch(selectedId, (id) => {
      if (id !== null) clearDraft()
    })
    
    async function onSaved(point: Point) {
      clearDraft()
      await refresh() // 一覧を取り直す → watch でピンが描き直される
      select(point.id) // 登録した地点をそのまま選択状態にする
    }
    // …(onMapReady と、ピンを描き直す watch は前回と同じ)
    </script>
    
    <template>
      <!-- 凡例と地図部分は前回と同じ。MapView に @map-click="onMapClick" を追加 -->
          <PointForm
            v-if="draftPosition"
            :position="draftPosition"
            @saved="onSaved"
            @cancel="clearDraft"
          />
          <PointDetail v-else :point="selectedPoint" @close="clearSelection" />
    </template>

    右側のパネルは「仮ピンがあれば登録フォーム、なければ詳細パネル」と切り替えます。保存に成功したら useFetch の refresh() で地点一覧を取り直します。第3回から watch([map, points]) でピンを描き直す仕組みがあるので、新しい地点のピンは自動で現れます。最後に登録した地点を select すると、第4回の InfoWindow と詳細パネルがそのまま開きます。

    フォームは仮ピンがある間は同じものを使い続けるので、名称を入力した後にピンをドラッグしても入力内容は消えません。

    動作確認の方法

    1. npm run dev → http://localhost:3000 を開く。詳細パネルに「地図の何もない場所をクリックすると、新しい地点を登録できます。」と出ることを確認
    2. 地図の空いている場所をクリック → 仮ピンが立ち、右側が登録フォームになることを確認
    3. 仮ピンをドラッグ → フォームの緯度・経度が変わることを確認
    4. 名称を入れて保存 → 新しいピンが立ち、詳細パネルに登録内容が出ることを確認
    5. ブラウザを再読み込み → 登録した地点が残っていることを確認
    6. 名称を空のままサーバーに送った場合(開発者ツールで required を外すなど)に、エラーメッセージが出ることを確認

    筆者の環境では、npm run build と npm run typecheck の成功、ビルド後のサーバーが返すHTMLに新しい案内文が入ること、フォームと同じ内容の POST /api/points で地点が登録されて件数が21件に増えること、名称が空だと 400 が返ることを curl で確認しました。地図のクリック、仮ピンの表示とドラッグ(マウス・キーボード)は、執筆環境に本物のAPIキーがないため確認できていません。 ご自身のキーを .env に設定して確認してください。

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

    • 仮ピンを useMarkers で一緒に管理する:保存済みのピンを描き直すたびに仮ピンが消えます。役割が違うものは分けて管理します
    • position をそのまま保存する:LatLng のままだと JSON にしたとき意図しない形になることがあります。{ lat, lng } の数値にそろえてから送ります
    • ブラウザ側のチェックだけで済ませる:サーバー側の検証(第3回の zod)は必ず残します
    • 二重登録:保存ボタンを連打すると同じ地点が2件できます。保存中はボタンを無効にしています
    • 本番では「誰が登録したか」が必要:連載では認証を省いていますが、実運用ではログインと登録者の記録、編集・削除の権限を決める必要があります

    発注者向けメモ

    • 地図クリックでの登録は、Google の追加API費用が発生しない構造です。地図の表示(マップロード)は第1回で説明したとおり課金対象ですが、クリックやドラッグは読み込み済みの地図の中の操作です
    • 座標の扱いが規約上シンプルになりやすいのも利点です。住所変換や施設検索で得た座標には、Google の利用規約で保存期間の制限があります(第6・7回で説明します)。一方、担当者が地図で決めた座標は自社で決めた値として扱いやすい方式です。ただし、最終的な解釈は規約本文と法務部門・専門家への確認をおすすめします
    • 工数を左右するのは入力項目と権限の要件です。写真の添付、必須項目の増加、承認フロー、登録者ごとの編集制限などが加わると、フォームとサーバーの作業が大きく増えます
    • スマホで現場から登録するかも早めに決めておきましょう。指での操作では「地図を動かしたつもりが登録になる」誤操作が起きやすく、長押しで登録するなどの工夫が必要になる場合があります

    発注者がやることのチェックリストです。

    • ☐ 登録時の入力項目と必須項目を一覧にした
    • ☐ 登録・編集・削除できる人(権限)を決めた
    • ☐ スマホから登録するかどうかを決めた
    • ☐ 座標を「人が地図で決める」か「住所から変換する」か、両方かを決めた

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

    • 「地点の登録で、Google のAPI費用が追加で発生する操作はありますか?」
    • 「入力値の検証はブラウザとサーバーの両方で行っていますか?」
    • 「スマホで地図を操作したときに、誤って登録されない工夫はありますか?」

    まとめと次回予告

    この回では、地図のクリックで仮ピンを立て、ドラッグで位置を調整してから登録する機能を作りました。仮ピンは保存済みのピンと分けて管理する、position は { lat, lng } にそろえる、検証はサーバー側で必ず行う、の3点が押さえどころです。

    次回は「住所を入力して緯度経度に変換する(Geocoding API v4をサーバー経由で)」です。住所から座標を求める Geocoding API を、ブラウザではなく Nuxt のサーバーから呼び、サーバー用のAPIキーを利用者に見せない構成を作ります。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (2件)

      目次