MENU

問い合わせ


    【NuxtとGoogle Mapsで作る情報集約マップ 第10回】現在地から半径◯km/描いたエリア内の地点を探す

    Google Maps API とNuxtで社内向けの情報集約マップを作る連載の第10回です。前回(第9回)では、クラスタリングと表示範囲の読み込みで、地点が数千件あっても地図が重くならないようにしました。今回は、ブラウザの位置情報で現在地を表示し、「現在地から半径◯km」や「地図上に描いた多角形のエリア」の中にある地点だけを一覧と地図に残す機能を作ります。

    「外回りの担当者が『今いる場所の近くの点検先』をすぐ探せるようにしたい。Google の追加料金はかかる?」

    結論から言うと、現在地の取得はブラウザの標準機能、円や多角形の表示と「内側かどうか」の判定は地図と一緒に読み込まれる機能で行うので、今回の機能で Google への追加のAPI呼び出しは発生しません。注意が必要なのは費用よりも、位置情報は HTTPS のページでしか使えないことと、Google の描画用ライブラリ(Drawing Library)が廃止済みであることの2点です。古いシステムで描画ライブラリを使っていた場合は、改修が必要になります。

    目次

    この回で作るもの:現在地・半径・描いたエリアでの絞り込み

    操作画面の動き
    「現在地」を押すブラウザが位置情報の許可を求め、許可すると地図が現在地へ移動して青い点が出る。誤差の目安(約◯m)も表示
    「半径で絞り込む」を押す現在地(なければ地図の中心)に円が出て、円の中の地点だけが一覧とピンに残る
    半径のスライダーを動かす・円のつまみをドラッグする円の大きさが変わり、一覧と件数が追従する
    「エリアを描く」→ 地図を3回以上クリック →「描き終える」多角形ができ、その内側の地点だけが残る。頂点はドラッグで直せる
    「エリアを解除」円・多角形を消して元の表示に戻る
    位置情報を拒否する「位置情報の利用が許可されていません」と表示する

    仕組み:使う機能と費用の関係

    やること使う機能Google への追加リクエスト
    現在地の取得ブラウザの navigator.geolocation(Geolocation API=ブラウザが端末の位置を教えてくれる仕組み)なし(Google の Geolocation API は使わない)
    円・多角形の表示と編集Maps JavaScript API の Circle / Polygon(editable: true)なし
    円の内側かgeometry ライブラリの spherical.computeDistanceBetween(2点間の距離をメートルで計算)なし
    多角形の内側かgeometry ライブラリの poly.containsLocationなし

    Google にも「Geolocation API」という名前の有料APIがありますが、これは Wi-Fi や携帯基地局の情報から位置を推定するサービスです。ブラウザで動く社内ツールなら、ブラウザ標準の位置情報で足りることがほとんどなので、本連載では使いません。

    📰 出典:MDN「Geolocation API」

    描画ライブラリ(Drawing Library)は廃止済み

    以前は、地図上に円や多角形を描く機能として Maps JavaScript API の Drawing Library(DrawingManager)がよく使われていました。これは 2025年8月に非推奨となり、2026年5月に利用できなくなっています。Google は代わりのライブラリとして Terra Draw を案内しています。

    📰 出典:Google Maps Platform「Deprecations」

    本連載では、描く図形が円と多角形だけなので、ライブラリを追加せず「地図をクリックして頂点を置く」描き方を自作します。四角形・線・自由な手描きなど多くの図形を扱うなら、Terra Draw の採用を検討してください(本連載では紹介のみで、動作は検証していません)。

    実装

    ファイル役割
    app/composables/useCurrentLocation.ts現在地の取得と、青い点の表示(新規)
    app/composables/useAreaFilter.ts円・多角形の作成と、内側かどうかの判定関数(新規)
    app/components/AreaTools.vue操作ボタンと半径のスライダー(新規)
    app/composables/usePointFilter.tsカテゴリの条件にエリアの条件を重ねる(更新)
    app/pages/index.vue組み合わせと、描画中の地図クリックの扱い(更新)

    手順1:現在地を取得する

    app/composables/useCurrentLocation.ts(要点)

      async function locate(map: google.maps.Map): Promise<google.maps.LatLngLiteral | null> {
        error.value = null
        if (!window.isSecureContext || !('geolocation' in navigator)) {
          error.value = 'この画面では位置情報を使えません(HTTPS で開いてください)。'
          return null
        }
        locating.value = true
        try {
          const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
            navigator.geolocation.getCurrentPosition(resolve, reject, {
              enableHighAccuracy: false, // true にすると GPS を使い、時間と電池を多く使う
              timeout: 10_000,
              maximumAge: 60_000, // 1分以内に取得した位置があれば再利用する
            }),
          )
          position.value = toLatLngLiteral({ lat: pos.coords.latitude, lng: pos.coords.longitude })
          accuracy.value = Math.round(pos.coords.accuracy)
          await showMarker(map, position.value)
          map.panTo(position.value)
          return position.value
        } catch (e) {
          error.value = toMessage(e)
          return null
        } finally {
          locating.value = false
        }
      }
    // GeolocationPositionError の code: 1=許可されなかった, 2=取得できない, 3=時間切れ
    function toMessage(e: unknown): string {
      const code = (e as { code?: number }).code
      if (code === 1) return '位置情報の利用が許可されていません。ブラウザの設定を確認してください。'
      if (code === 3) return '現在地の取得に時間がかかりすぎました。もう一度お試しください。'
      return '現在地を取得できませんでした。'
    }

    位置情報は、HTTPS のページ(開発中の localhost は例外として可)でしか使えません。window.isSecureContext で先に確かめ、使えない場合は理由を画面に出します。getCurrentPosition はコールバック形式なので、Promise に包んで await で書けるようにしました。

    coords.accuracy は誤差の目安(メートル)です。パソコンでは Wi-Fi などから推定するため、数百メートル以上ずれることも珍しくありません。「近くの点検先」の精度が業務上どこまで必要かは、実際の端末で確かめておきましょう。現在地の青い点は、第2回と同じく AdvancedMarkerElement に div を append して作っています(見た目は main.css の .current-location-dot)。

    📰 出典:MDN「Geolocation: getCurrentPosition() メソッド」

    手順2:半径の円で絞り込む

    app/composables/useAreaFilter.ts(円の部分)

      // 地点が範囲内かを返す関数。範囲の指定がなければ null(=絞り込まない)
      const matcher = shallowRef<((p: Point) => boolean) | null>(null)
    
      async function startCircle(center: google.maps.LatLngLiteral) {
        if (!map.value) return
        const [{ Circle }, { spherical }] = await Promise.all([load('maps'), load('geometry')])
        clearArea()
        circle = new Circle({
          ...SHAPE_STYLE,
          map: map.value,
          center,
          radius: radiusKm.value * 1000, // メートル
          editable: true, // 中心と外周のつまみをドラッグして直せる
        })
        const update = () => {
          const c = circle?.getCenter()
          const r = circle?.getRadius()
          if (!c || !r) return
          radiusKm.value = Math.round(r / 100) / 10 // 0.1km 単位で表示
          matcher.value = (p) => spherical.computeDistanceBetween(p, c) <= r
        }
        circle.addListener('radius_changed', update)
        circle.addListener('center_changed', update)
        update()
        mode.value = 'circle'
        fitToArea()
      }

    この composable の中心は matcher(地点を受け取って「範囲内なら true」を返す関数)です。円が動いたり大きさが変わったりするたびに新しい関数に差し替えるので、絞り込み結果(computed)が自動で計算し直されます。円の editable: true により、利用者は中心と外周のつまみをドラッグして直接調整できます。スライダーで半径を変えたときは circle.setRadius() を呼ぶだけで、radius_changed イベント経由で同じ update が動きます。

    computeDistanceBetween は地球を球として2点間の最短距離をメートルで返します。緯度・経度の差をそのまま使うより正確で、自分で計算式を書く必要もありません。

    📰 出典:Google Maps Platform「Geometry Library」

    手順3:クリックで多角形を描いて絞り込む

    app/composables/useAreaFilter.ts(多角形の部分)

      async function startDrawing() {
        if (!map.value) return
        const { Polyline } = await load('maps')
        clearArea()
        // 描いている途中は線で表示する。clickable: false でクリックを地図側に通す
        drawingLine = new Polyline({ ...SHAPE_STYLE, map: map.value, clickable: false })
        vertices = []
        vertexCount.value = 0
        mode.value = 'drawing'
      }
    
      // 描画中に地図がクリックされたら頂点を足す(pages/index.vue から呼ぶ)
      function addVertex(pos: google.maps.LatLngLiteral) {
        if (mode.value !== 'drawing' || !drawingLine) return
        vertices = [...vertices, pos]
        drawingLine.setPath(vertices)
        vertexCount.value = vertices.length
      }
    
      async function finishDrawing() {
        if (!drawingLine || !map.value || vertexCount.value < 3) return
        const [{ Polygon }, { poly }] = await Promise.all([load('maps'), load('geometry')])
        drawingLine.setMap(null)
        drawingLine = null
    
        const shape = new Polygon({ ...SHAPE_STYLE, map: map.value, paths: vertices, editable: true })
        polygon = shape
        // 頂点を動かす・辺の中点を引いて頂点を足す・頂点を消す、のどれでも判定を作り直す
        const update = () => {
          matcher.value = (p) => poly.containsLocation(p, shape)
        }
        const editPath = shape.getPath()
        editPath.addListener('set_at', update)
        editPath.addListener('insert_at', update)
        editPath.addListener('remove_at', update)
        update()
        mode.value = 'polygon'
        fitToArea()
      }

    描いている途中は線(Polyline)で表示し、「描き終える」で多角形(Polygon)に置き換えます。途中の線を clickable: false にしているのは、線の上をクリックしても地図のクリックとして扱い、頂点を置けるようにするためです。

    完成した多角形は editable: true なので、頂点のドラッグや、辺の中点を引っ張って頂点を増やす操作ができます。形が変わると、多角形の頂点の並び(getPath() が返す MVCArray=変更を通知してくれる配列)から set_at・insert_at・remove_at のイベントが届くので、そのたびに判定を作り直します。

    手順4:範囲全体を表示して、取りこぼしを防ぐ

      // 範囲全体が見えるように地図を合わせる(第9回の表示範囲読み込みで、範囲内の地点が取得される)
      function fitToArea() {
        const bounds = circle?.getBounds() ?? polygonBounds()
        if (bounds && map.value) map.value.fitBounds(bounds, 24)
      }

    第9回から、画面が持っている地点は「地図に見えている範囲」だけです。半径10kmの円を置いても、地図が1km四方しか表示していなければ、円の中でも画面外の地点は判定できません。そこで、円や多角形を作ったり大きさを変えたりしたら fitBounds で範囲全体が見えるまで地図を合わせています。地図が動くと第9回の仕組みで範囲内の地点が取得され、その後で円・多角形の判定がかかります。

    この作り方は「範囲内の地点が1回の上限(10,000件)に収まる」前提です。広いエリアで大量の地点を扱うなら、判定をサーバー側(PostgreSQL の PostGIS など)で行う設計に変えます。

    手順5:カテゴリの絞り込みと重ねる

    app/composables/usePointFilter.ts(変更部分)

    export function usePointFilter(
      points: Ref<Point[]>,
      // 第10回: エリア(半径・多角形)の判定。null なら絞り込まない
      areaMatcher?: Ref<((p: Point) => boolean) | null>,
    ) {
      const visibleCategories = useState<Category[]>('visible-categories', () => [...CATEGORIES])
    
      const filteredPoints = computed(() => {
        const inArea = areaMatcher?.value
        return points.value.filter(
          (p) => visibleCategories.value.includes(p.category) && (!inArea || inArea(p)),
        )
      })

    app/pages/index.vue(追加・変更部分)

    const map = shallowRef<google.maps.Map | null>(null)
    const currentLocation = useCurrentLocation()
    const area = useAreaFilter(map)
    const { visibleCategories, filteredPoints, countByCategory } = usePointFilter(points, area.matcher)
    
    // 第10回: エリアを描いている間は、クリックは多角形の頂点になる
    function onMapClick(position: google.maps.LatLngLiteral) {
      if (!map.value) return
      if (area.mode.value === 'drawing') {
        area.addVertex(position)
        return
      }
      clearSelection()
      setDraft(map.value, position)
    }
    
    // 半径の円は、現在地があれば現在地、なければ地図の中心に置く
    function onStartCircle() {
      const center = currentLocation.position.value ?? map.value?.getCenter()?.toJSON()
      if (center) area.startCircle(center)
    }
    
    // エリアの絞り込みで選択中の地点が外れたら、選択を解除する
    watch(area.matcher, (inArea) => {
      if (inArea && selectedPoint.value && !inArea(selectedPoint.value)) clearSelection()
    })

    地図のクリックは、第5回から「新しい地点の登録(仮ピン)」に使っています。エリアを描いている間だけはクリックの意味を「頂点を置く」に切り替え、描き始めるときに登録途中の仮ピンを片付けます。1つの操作に2つの意味を持たせるときは、今どちらのモードかを画面に明示するのが大切です。AreaTools.vue では描画中に「地図をクリックして頂点を置いてください(◯点)」と表示しています。

    AreaTools.vue は表示だけを担当し、押されたボタンをイベントで親に伝えます。円や多角形の実体(Google Maps のオブジェクト)は useAreaFilter の中に1つだけ持ち、ページから一度だけ呼び出しています。同じ composable を複数のコンポーネントで呼ぶと、図形が2つできてしまうためです。

    動作確認の方法

    1. npm run dev → http://localhost:3000 を開く(localhost は位置情報を使える例外。スマートフォンなど別の端末から試す場合は HTTPS が必要)
    2. 「現在地」→ 位置情報を許可 → 地図が移動して青い点と誤差が出ることを確認
    3. 「半径で絞り込む」→ 円が出て、円の外の地点が一覧とピンから消えることを確認。スライダーやつまみで半径を変え、件数が変わることを確認
    4. 「エリアを描く」→ 地図を4回ほどクリック →「描き終える」→ 多角形の内側だけが残ることを確認。頂点をドラッグして結果が変わることを確認
    5. ブラウザの設定で位置情報を「ブロック」にして「現在地」→ 許可されていない旨のメッセージが出ることを確認

    筆者の環境では、npm run build と npm run typecheck の成功に加え、ビルド後のサーバーが返すHTMLに操作ボタン(現在地・半径で絞り込む・エリアを描く)が地図の準備前は押せない状態で出力されることを curl で確認しました。位置情報の許可・拒否、円と多角形の表示・編集、内側判定による絞り込みは、執筆環境に本物のAPIキーとブラウザでの操作環境がないため確認できていません。 ご自身のキーを設定して確認してください。

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

    • HTTP の検証環境で位置情報が動かない:位置情報は HTTPS 必須です。社内の検証サーバーが HTTP のままだと、そこでだけ動かないという事態になります
    • 拒否されたときの表示がない:一度「ブロック」されると、ブラウザの設定を変えるまで許可の確認は出ません。理由と対処を画面に出します
    • 精度を過信する:パソコンの位置は数百メートル以上ずれることがあります。誤差の目安を画面に出し、必要なら住所検索(第6回)と併用します
    • 円や多角形の中をクリックしても登録できない:円・多角形はクリックを受け取るため、その内側のクリックは地図に届きません。登録は円の外で行うか、エリアを解除してから行う仕様にしています
    • 位置情報の扱い:今回は現在地をサーバーに送らず、保存もしません。現在地を記録する機能(訪問記録など)を追加する場合は、従業員の位置情報という個人に関わる情報になるため、利用目的の説明や社内規程の確認が必要です

    発注者向けメモ

    • 現在地の取得・半径やエリアの判定には、Google の追加API費用がかからない構造です。Google の Geolocation API(有料)を使う提案があった場合は、ブラウザ標準の位置情報で足りないのか理由を確認しましょう
    • Google の描画ライブラリは2026年5月に廃止済みです。既存システムでエリアを描く機能を使っている場合は、動かなくなっている可能性があり、改修が必要です。保守契約の範囲で対応してもらえるかも確認しておきましょう
    • HTTPS の用意は前提条件です。本番だけでなく、社内の検証環境も HTTPS にしておかないと、受け入れテストで位置情報を確認できません
    • 「どこまで正確な位置が必要か」を業務で決めておくと、スマートフォンの GPS を使うか、アプリ化が必要かなどの判断がしやすくなります

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

    • ☐ 現在地を使う業務(誰が・どの端末で・どこで)を洗い出した
    • ☐ 必要な位置の精度(数十m・数百mなど)を決めた
    • ☐ 検証環境・本番環境が HTTPS になっていることを確認した
    • ☐ 現在地を保存するかどうかと、その場合の社内ルールを決めた
    • ☐ 既存システムで Google の描画ライブラリを使っていないか確認した

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

    • 「現在地の取得に、Google の有料APIを使いますか?ブラウザの機能で足りませんか?」
    • 「エリアの描画には何を使いますか?廃止された描画ライブラリに依存していませんか?」
    • 「位置情報をブロックされたときや、精度が低いときの画面はどうなりますか?」

    まとめと次回予告

    この回では、ブラウザの位置情報で現在地を表示し、半径の円や自分で描いた多角形の中にある地点だけを残す機能を作りました。判定は「地点を受け取って範囲内かを返す関数」を1つ持ち、それをカテゴリの絞り込みに重ねる、というのが押さえどころです。廃止された描画ライブラリの代わりに、Polyline と Polygon で描き方を自作しました。

    次回は「CSVで既存の台帳を一括取り込みする」です。Excel などで管理している既存の台帳をCSVで読み込み、住所しかない行はサーバーでまとめて緯度・経度に変換して登録します。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (1件)

      目次