3
3

[個人開発] Geolocation APIを使って位置情報取得&比較する

Last updated at Posted at 2024-09-11

はじめに

前回記事、[個人開発]Ruby on Railsで阪神ファンのためのアプリを開発しましたでのメイン機能である、位置情報の取得&比較についての記事を作成いたしました!

位置情報を取得するのはすでに素晴らしい記事があるので、そちらを参考にしていただければいいかなと思います!
Geolocation APIを使って現在位置を取得する

開発環境

・asset pipeline ( webpackerは不使用 )
・esbuild

完成物

Image from GyazoImage from Gyazo

手順

1. GCPでアカウント登録をする
2. API_KEYを.envに記述する
3. Google Mapで緯度経度を取得し、seeds.rbに保存する
4. ルーティング、MVCの作成
5. Javascriptファイルの作成
6. Javascript ファイルにデータを渡すためのapi/SeatControllerの作成

1. GCP(Gppgle Cloud Platform)でアカウント登録をする

すでに素晴らしい記事がたくさんあるので、どれか参考にしてアカウント登録してください。
以下の記事が写真説明が多くて参考になるかもです。
Google Cloudの始め方(アカウント作成編)

2. API_KEYを.envに記述する

上記で作成したプロジェクトのAPIを有効にする。
以下記事参考にしてください。API Keyを発行する手順 for Google Cloud API
上記の手順では Google Cloud Vision API を取得していますが、これと同じ手順でGeolocation API を有効にしてください。
そして取得したAPI _KEYを.envファイル( gem 'dotenv-rails'を使用 )に記述してください。
デプロイ先の環境変数に入れることもお忘れなく!
これでGolocation API を使用する準備ができました。

3. Google Mapで緯度経度を取得し、seeds.rbに保存する

⚠️ここからかなりボリュームが増えます!⚠️

座席情報が取得できるAPIやサイトがなかったので、自力で取得していきます。
まず、Google Mapで取得したい位置情報を調べます。
左クリックでその地点の緯度軽度が出てきます。
Image from Gyazo
一番上の数字の左が緯度で、右が軽度です。分割したらそのまま使えます。

ちなみにこの値はめちゃめちゃ細かいところまでピンポイントで指定されています。

そして左クリックの一番下の項目の「距離を測定」というところをクリックすると、
Image from Gyazo
このように地図上に線が引けるので、これでシートごとの範囲を取得していくという形です。

この点はドラッグ&ドロップで移動させることができて、点を左クリックしたら緯度と経度もバッチリわかるので最高です。

そしてシートごとの範囲と言っても、この地図じゃ分かりにくいので、航空写真にして、甲子園の全体のシート分けが載っている画像と比べながら範囲を取っていきました。

Image from Gyazo
座席の色が違うので、航空写真だと分かりやすいですね!

実際に1塁アルプス〜3塁アルプスまで通路移動しながらテストしましたが、なかなか正確に範囲取れていたので、この方法結構ありです。

そして取得した範囲データですが、seeds.rbに記述していきます。

seeds.rb
seats = [
  { id: 1, location_id: 1, location_type: 'backnet', seat_name: "backnet", spots: [ { lat: 34.722062259253946, lng: 135.36080775915224 }, { lat: 34.721825961386614, lng: 135.3613683703013 }, { lat: 34.721912604016396, lng: 135.36145142380488 }, { lat: 34.72191785508195, lng: 135.36164787728447 }, { lat: 34.72185878057524, lng: 135.36175329134667 }, { lat: 34.722176469647735, lng: 135.36231070813025 }, { lat: 34.72239438789241, lng: 135.36196571665388 }, { lat: 34.722331375446956, lng: 135.3610217816422 }] },

seatsテーブルにシートごとの配列にデータを入れていくので、seatsに配列でデータを記述していきます。
緯度軽度の範囲は spots という配列で、緯度軽度の組み合わせで入れていきたいので、テーブルのカラムを jsonb というJSON方式でデータを入れられる形にします。
spotsにも緯度(lat)と経度(lng)を配列にして、取得した組み合わせで入れていきます。
以下の部分です。

seeds.rb
spots: [ { lat: 34.7..., lng: 135.3... }, { lat: 34.7..., lng: 135.3... }, ... ]

[ ]のspots配列のなかに、{ lat: , lng: } 緯度軽度の配列を入れていく、というかたちです。
これで該当の座席たちを登録していきます。

3. ルーティング、 MVCの作成

ここからはユーザーの位置情報を取得して該当する位置を返すロジックを作成していきます。
私のアプリでは seats とlocations と user_locations というモデルで役割を分けて記述しています。
それぞれのモデルの詳しい内容は以下のテーブルの通りです。
seats locations user_locations
座席を表すモデル 場所を表すモデル ユーザーの位置を表すモデル
実際の甲子園球場の緯度経度データを保持 アプリ内のアイコンの表示データを保持 ユーザーの位置情報を保持 
ユーザーの緯度軽度を比較してどの座席に該当するか比較する ユーザーの緯度軽度を取得し、結果を返す ユーザーの位置情報とアイコンを登録する 
モデルとテーブルデータしか持たない
(バックエンド機能のみ)
モデルとテーブルデータしか持たない
(バックエンド機能のみ)
MVCモデルを持つ
(実際のユーザーが操作するのはこっち)

user_locationsがフロントで、locationsがミドルで、seatsがバックエンドみたいな感じです!
locationsで取得したユーザーの位置情報をseatsが保持しているデータと比較して、該当の範囲のシート名を返す、という形にしています。
今回アイコンの表示まではしないので、この流れで必要な部分のみ取り扱っていきます。


・ ルーティングの作成

user_locations の new , create アクションに対応するルーティングを記述していきます。
routes.rb
Rails.application.routes.draw do
  resources :user_locations, only: %i[new create]
end

seats と locations は表示するビューがないので、ルーティングの記述は不要です。

・ モデルの作成

まずseat.rbを作成していきます。
seat.rb
class Seat < ApplicationRecord
  belongs_to :location
end

各シートは特定の location に属しているので、belongs_to 関連付けになります。
user_location とは直接の関連付けを持たないので記述は不要です。


location.rb を作成します。

location.rb
class Location < ApplicationRecord
  attribute :points, :jsonb, default: {}
  has_many :user_locations
  has_many :seats
  has_many :users, through: :user_locations
  enum location_type: {
    backnet: 1,
    smbc_seat: 2,
    ivy_seat: 3,
    breeze_seat: 4,
    first_base_alps: 5,
    third_base_alps: 6,
    right_outfield: 7,
    left_outfield: 8,
    home_cheering: 9
  }

  def seat_name_ja
    I18n.t("seats.#{seat_name}")
  end
end

・ attribute :points, :jsonb, default: {}
pointsという名前のjsonb型の属性を定義し、そのデフォルト値を空のJSONオブジェクトに設定します。

・ has_many関連付け
user_locations は当日はもちろん、過去データも保持する必要があるので has_many 関連付けをします。
seats はユーザーの位置が複数の座席に属している可能性があるので、 has_many 関連付けをします。
users は locations が user_locations と users の中間テーブルになるので、has_many 関連付けをします。

・ enum location_type
enumでシート範囲ごとに整数値と名前を設定しています。

・ seat_name_ja
座席名を日本語表示するために国際化(I18n)をしています。


そしてuser_location.rbを作成していきます。

user_location.rb
class UserLocation < ApplicationRecord
  belongs_to :user
  belongs_to :location
  enum location_type: Location.location_types

  validates :location_id, presence: true

 
 def self.create_with_seat(location_params, user)
  user_location = new(location_params.merge(user_id: user.id))

  user_location.save

  user_location
 end
end

・ belongs_to 関連付け
user_location は user と location に関連づいており、2つの中間テーブルとして機能しているので belongs_to 関連付けをします。

・ enum icon, location_type
enumでシート範囲に整数値と名前を設定しています。

・ validates
シート範囲の登録がnil場合はバリデーションエラーを発生させるように設定します。

・ self.create_with_seat(location_params, user)
location_params(UserLocationの属性を含むハッシュ)とuser(Userインスタンス)を引数に取り、新しいUserLocationインスタンスを作成し、データベースに保存します。

・ コントローラの作成

user_locations_controller.rb
class UserLocationsController < ApplicationController
  before_action :authenticate_user!, only: [:create]
  before_action :validate_location_id, only: [:create]
  
  def new
    @user_location = UserLocation.new
    @location = Location.find_by(id: params[:location_id])
  end

  def create
   last_user_location = current_user.user_locations.order(created_at: :desc).first
 
   if last_user_location && last_user_location.created_at.to_date == Date.today
     flash[:danger] = t('user_locations.create.already')
     redirect_to root_path and return
   end

   @user_location = UserLocation.create_with_seat(user_location_params.merge(icon: user_location_params[:icon].to_i), current_user)

   unless @user_location.persisted?
     flash.now[:danger] = t('user_locations.create.failure')
     render :new, status: :unprocessable_entity and return
   end

   redirect_to root_path
 end

  private

  def user_location_params
    params.require(:user_location).permit(:location_id)
  end

  def validate_location_id
    unless Location.exists?(user_location_params[:location_id])
      flash[:danger] = t('user_locations.create.invalid_location')
      redirect_to new_user_location_path
      return
    end
  end
end

・ before_action :authenticate_user!, only: [:create]
create メソッドのみで使用される、ユーザーがログインしているかどうかを検証するメソッドです。
deviseでのデフォルトメソッドです。

・ before_action :validate_location_id, only: [:create]
create メソッドのみで使用される、位置情報のIDが有効であることを確認するためのメソッドです。
位置情報のIDが無効な場合、フラッシュメッセージに警告を設定し、新しいユーザー位置情報の作成ページにリダイレクトします。
これは悪意のあるユーザーがブラウザの開発者ツールを使用してセレクトボックスの値を変更したり、APIリクエストを直接操作したりすることで、意図しない値を送信できないようにするためです。

・ new
新しい UserLocation インスタンスを作成し、それを@user_locationに格納します。
また、Locationモデルから指定されたIDのレコードを検索し、それを@locationに格納します。

・ create
ユーザーが新しいUserLocationを作成するためのメソッドです。
まず、ユーザーの最新のUserLocationを取得します。その日にすでにUserLocationが作成されている場合、エラーメッセージを表示し、ルートパスにリダイレクトします。
次に、新しいUserLocationを作成します。
作成に失敗した場合、エラーメッセージを表示し、新規作成ページに戻ります。
作成に成功した場合、ルートパスにリダイレクトします。

・ user_location_params
ストロングパラメーターを定義します。
これにより、location_idのみが許可され、他のパラメーターは拒否されます。

・ ビューの作成

user_locations/new.html.erb
<%= form_with model: @user_location, local: true do |form| %>
  <%= render 'shared/error_messages', object: @user_location %>
  <div class="btn-margin">
    <small>ボタンを押す前にページをリロードしてください</small><br>
    <button id="location-btn" class="btn btn-outline-dark btn-lg" type="button">
      現在位置を取得する
    </button>
    <div class="txt-margin" style="display: none;">
      <p>緯度:<span id="latitude">???</span><span>度</span></p>
      <p>経度:<span id="longitude">???</span><span>度</span></p>
    </div>
  </div>
  <div class="txt-margin mt-3">
    <h5>選択されたシート:<span id="selected-seat">???</span></h5>
    <div id="seat-select-popup" style="display: none;">
      <p>この位置でよろしいですか?</p>
      <%= form.select :location_id, Seat.all.map { |seat| [I18n.t("user_location.seat.#{seat.seat_name}"), seat.id] }, { selected: @seat.location_id }, { onchange: 'updateSelectedSeat()', id: 'location_id' } %>
      <button id="yes-btn" class="btn btn-secondary btn-sm" type="button">登録</button>
      <div id="seat-select" style="display: none;"></div>
    </div>
  </div>
  <div class="text-center mb-5">
    <%= form.submit I18n.t('user_location.seat.submit'), class: 'btn btn-primary' %>
  </div>
<% end %>

以下が解説です。

<%= form_with model: @user_location, local: true do |form| %>

form_withヘルパーを使用して、@user_locationモデルに基づくフォームを作成します。local: trueは、フォームの送信をAjaxではなく通常のHTTPリクエストで行うことを指定します。

<button id="location-btn" class="btn btn-outline-dark btn-lg" type="button">

現在位置を取得するためのボタンを作成します。このボタンはJavaScriptで操作され、ユーザーの現在位置を取得します。

<%= form.select :location_id, Seat.all.map { |seat| [I18n.t("user_location.seat.#{seat.seat_name}"), seat.id] }, { selected: @seat.location_id }, { onchange: 'updateSelectedSeat()', id: 'location_id' } %>

Seatモデルから全ての座席を取得し、それらを選択肢とするセレクトボックスを作成します。選択された座席のIDはlocation_idパラメータとして送信されます。


かなり内容が多かったですが、ルーティングとMVCの記述は以上です!
これでユーザーの見た目とバックの仕組みの作成はできたので、次は動きである上記の現在位置を取得するボタンを動かす Javascript ファイルを作成していきます。

5. Javascriptファイルの作成

ここまで長かったですが、もう山は超えたので、あと少し頑張っていきましょう!
先ほどのボタンを動かすJavascriptの記述はlocation.jsに記述していきます。
記述が長いので段落で分けると、
・ページが読み込まれてボタンがクリックされた時
・位置情報の取得に成功した時
・ユーザーの位置情報と座席データを受け取り、ユーザーがどの座席エリアに位置しているかを判定する
・ユーザーの位置がしーと範囲内にあるかどうかを判定
・サーバーから座席データを非同期に取得する
・選択された座席を更新する
となっています。

location.js
//ページが読み込まれてボタンがクリックされた時
window.onload = function() {
  var locationBtn = document.getElementById("location-btn");
  if (locationBtn) {
    locationBtn.onclick = function() {
      navigator.geolocation.getCurrentPosition(successCallback, errorCallback);
    };  //ユーザーの現在位置を取得。成功した場合はsuccessCallback関数が、失敗した場合はerrorCallback関数が呼び出される。
  }

//位置情報取得に成功したとき
  function successCallback(position){
    var latitude = position.coords.latitude;
    var longitude = position.coords.longitude;
    document.getElementById("latitude").innerHTML = latitude;
    document.getElementById("longitude").innerHTML = longitude;
  //取得した緯度と経度をHTML要素に表示し、その位置情報を元に座席データを取得している。
    var userLocation = {
      lat: latitude,
      lng: longitude
    };

    getSeatsData(function(seats) {
      seats.forEach(seat => {
        if (seat.spots) {
          seat.spots = JSON.parse(seat.spots); // spotsをJSONとしてパースしてJavaScriptのオブジェクトに変換
        }
      });
      checkUserLocation(userLocation, seats);  //ユーザーの位置がどの座席エリアにあるかを判定
    });
  };

//位置情報取得に失敗したときに呼び出される関数
  function errorCallback(error){
    alert("位置情報が取得できませんでした");
  }
}

// /api/seatsエンドポイントから座席データを非同期に取得
function getSeatsData(callback) {
  fetch('/api/seats')
    .then(response => {
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      return response.json();
    })
    .then(data => callback(data))
    .catch(error => console.error('Error:', error));
}

//ユーザーの位置がシート範囲の内部にあるかどうかを判定する
function checkUserLocation(userLocation, seats) {
  function isPointInPolygon(point, polygon) {
    var x = point.lng, y = point.lat;

    var inside = false;
    for (var i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
      var xi = polygon[i].lng, yi = polygon[i].lat;
      var xj = polygon[j].lng, yj = polygon[j].lat;

      var intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
      if (intersect) inside = !inside;
    }

    return inside;
  };

  var userInSeat = false;
  var seatName;
  for (var i = 0; i < seats.length; i++) {
    if (seats[i].spots && isPointInPolygon(userLocation, seats[i].spots)) {
      location_id = seats[i].id;
      seatName = seats[i].seat_name;
      userInSeat = true;
      break;
    }
  }

  if (!userInSeat) {
    location_id = 9; // デフォルトのlocation_idを設定
    for (var i = 0; i < seats.length; i++) {
      if (seats[i].id === location_id) {
        seatName = seats[i].seat_name;
        break;
      }
    }
  }

  document.getElementById('selected-seat').textContent = seatName;

  var seatSelectPopup = document.getElementById("seat-select-popup");
  var seatSelect = document.getElementById('location_id');
  var selectBox = document.getElementById('location_id');

  if (selectBox) {
    selectBox.value = location_id; // セレクトボックスの初期値を設定
  }

  if (seatSelectPopup) {
    seatSelectPopup.style.display = "block";
    document.getElementById("seat-select").style.display = "block";
    document.getElementById("yes-btn").onclick = function() {
      updateSelectedSeat();  //選択された座席を更新する
        document.getElementById("seat-select").style.display = "none";
        document.getElementById("seat-select-popup").style.display = "none";
      };
  } else {
    console.error('Error: "seat-select-popup" element is not found.');
  }
}

//選択された座席を更新する
function updateSelectedSeat() {
  var selectBox = document.getElementById('location_id');
  var selectedSeat = selectBox ? selectBox.options[selectBox.selectedIndex].text : null;
  var selectedSeatElement = document.getElementById('selected-seat');

  if (selectedSeatElement) {
    selectedSeatElement.textContent = selectedSeat;
    selectedSeatElement.dataset.locationId = selectBox ? selectBox.value : null;

    if (selectBox) {
      selectBox.value = selectedSeatElement.dataset.locationId;
    }
  } else {
    console.error('Error: "selected-seat" element is not found.');
  }
}

isPointInPolygon の部分が少しわかりにくいので、解説。
具体的には、レイキャスティング(光線投射)というアルゴリズムを使用しています。
このアルゴリズムは、点から任意の方向に無限に伸びる線(レイ)を引き、その線が多角形の辺を何回交差するかを数えます。交差回数が奇数なら点は多角形の内部にあり、偶数なら外部にあると判定します。
このアルゴリズムは、点が多角形の内部にあるかどうかを効率的に判定するためのもので、地理情報システム(GIS)などでよく使用されるみたいです。
範囲外の時はデフォルトで「自宅から応援」となるようにしているので、それが選択されることになります。

/api/seatsエンドポイントから座席データを非同期に取得の部分は次で詳しく解説していきます。

6. Javascript ファイルにデータを渡すためのapi/SeatControllerの作成

先ほどのlocation.jsファイルの function getSeatsData(callback) を動作させるために、api/seats_controller.rbを作成します。

controllers_api_seats_controller.rb
module Api
  class SeatsController < ApplicationController
    def index
      @seats = Seat.all
      render json: @seats, each_serializer: Api::SeatSerializer
    end
  end
end

これは全ての座席(Seat)を取得し、それらをJSON形式で返すAPIエンドポイントを提供しています。
seeds.rb に保存してあるJSON方式の spots データを location.js で使いたいのですが、直接データをやり取りすることができないので、Ruby on RailsのActiveModel::Serializerを使用して、Seat モデルのインスタンスを JSON 形式にシリアライズ(変換)します。
つまり、APIは「通信手段」であって、この api/seats_controller も seeds.rb ファイルと location.js の橋渡し役です。
このコントローラで通信の定義をして、データのやり取りを可能にしています。

そしてここで使用する SeatSerializer の内容を定義する seat_serializer.rb も作成します。

app/serializers/api/seat_serializer.rb
module Api
  class SeatSerializer < ActiveModel::Serializer
    attributes :id, :seat_name, :latitude, :longitude, :created_at, :updated_at, :location_type, :spots, :location_id

    def seat_name
      I18n.t("seats.#{object.seat_name}")
    end
  end
end

attributes でシリアライズの対象を指定して、これらのデータを JSON 形式に変換することで、Javascriptファイルでこれらのデータが直接使用できるようになります。


そして最後にapi/seats_controller.rb を使用できるようにルーティングに追記します。

routes.rb
Rails.application.routes.draw do
  resources :user_locations
  # --- 以下を追記 --- #
  namespace :api do
    resources :seats, only: [:index]
  end
  # --- ここまで --- #
end

長かったですが、これで位置情報を取得して、seeds.rbで定義した範囲から現在位置を取得する機能の実装が完了しました!

最後に

最後までお読みいただきありがとうございました!

甲子園球場のシート範囲のように詳細情報のない場所でもこのロジックで範囲を選択することが可能になるので、活用方法を変えればたくさんのことに使用できるかと思います!
例えば住所指定の難しい広い公園や農地、砂浜など、結構いろんなことに使えそうだなと思います!
ぜひ皆様の参考になれば嬉しいです^^
初学者のため、間違った見解や解説もあると思いますが、もしよければ優しくご教示いただけるととても喜びます。

参考記事

【初心者向け】Google Maps APIの 全体像を徹底解説!

【Rails】現在地を取得し、緯度経度をもとに現在地から近い店舗を検索

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3