WEBサービスから情報を取得して地図に表示する

ガソリン価格がとても気になる。現在は下落傾向だが、スタンドに実勢価格として反映するタイミングや地域によっては、スタンドごとに価格差が発生している。ガソリンというのはつくづく価格勝負の商品で、一消費者としては1円でも安く給油したいと考えてしまう。ただし、安いスタンドを求めて車を走らせ過ぎてしまうと、逆に高く付いてしまう可能性もある。悩ましい。地元周辺のスタンド価格地図が欲しい!
実は、そんな地図は既に公開されていた。ガソリン価格比較サイト gogo.gsにアクセスして、登録してログインすれば、ガソリンマップでスタンド価格を確認することができる。
しかし、ログインしてスタンド地図を開くまでの手間が面倒だったり、地図上の価格アイコンの示すスタンド位置が分かり難かったりと、若干の不満がある。検索の条件は細かく指定できなくてもいいので、素早くページを開いてサッと確認できる地図にしたい。できれば、iPodTouchやiPhoneでも見易く表示できるようにしたい。
素晴らしいことに、gogo.gsさんではスタンド価格情報を取得できるwebサービスAPIを公開してくれている。これを利用させて頂ければ、自分好みの地図を作ることができるはず。

サンプルリクエストの確認

<PriceInfo> 
    <Version>1</Version> 
    <ResultCountTotal>363</ResultCountTotal> 
    <ResultCount>50</ResultCount>
    <Item> 
	    <ShopCode>1410000008</ShopCode> 
	    <Brand>ESSO</Brand> 
	    <ShopName>Express コンフォート中原SS / (株)木所</ShopName> 
	    <Latitude>35.572018828091</Latitude> 
	    <Longitude>139.660072624683</Longitude> 
	    <Address>神奈川県川崎市中原区市ノ坪131</Address> 
	    <Price>135</Price> 
	    <Date>2008/10/25</Date> 
	    <Photo>1218893783</Photo> 
	    <Rtc>24H</Rtc> 
	    <Self>SELF</Self> 
    </Item> 
    <Item> 
	    <ShopCode>1403000330</ShopCode> 
	    <Brand>ENEOS</Brand> 
	    <ShopName>Dr.Drive 新丸子店 / 木内油業(株)</ShopName> 
	    <Latitude>35.5814321743262</Latitude> 
	    <Longitude>139.663642644882</Longitude> 
	    <Address>神奈川県川崎市中原区丸子通1-636-2</Address> 
	    <Price>135</Price> 
	    <Date>2008/10/26</Date> 
	    <Photo>1221905498</Photo> 
	    <Rtc></Rtc> 
	    <Self></Self> 
    </Item>
...(中略)...
</PriceInfo> 

GogoGsモデルの作成

  • 店舗情報をハッシュの配列として取得することを目標にして、以下のようにやってみた。
# ---------- app/models/gogo_gs.rb ----------

require 'uri'
require 'open-uri'

class GogoGs
  HOST = 'api.gogo.gs' 
  PATH = '/v1.1/'
  APID = 'guest'
  NUM  = 26
  SPAN = 30
  
  def self.find_in_area(lon, lat, dist=4, kind=0)
    uri = URI::HTTP.build({:host => HOST, :path => PATH})
    uri.query =  "apid=#{APID}"
    uri.query << "&lon=#{lon}"
    uri.query << "&lat=#{lat}"
    uri.query << "&dist=#{dist}"
    uri.query << "&kind=#{kind}"
    uri.query << "&num=#{NUM}"
    uri.query << "&span=#{SPAN}"
    begin
      timeout(5){@price_info = Hash.from_xml(uri.read)['PriceInfo']}
    rescue Exception, TimeoutError
      @price_info = {}
      p "*"*80
      p "cannot connect to #{uri}"
      p $!
    end
    @price_info['Item']
  end
end

店舗情報を地図に重ねる

  • 前回の地図にGogoGsで取得した情報を重ね合わせてみる。
    • [1]は、初めて地図にアクセスした場合、位置情報をjavascriptの環境で修正して、gogo.gsで取得する時のruby環境の位置と異なるため、再読込している。
    • [2]と[3]で、店舗情報をハッシュの配列として取得して、マーカーとその吹き出しウィンドウを準備している。
# ---------- app/controllers/maps_controller.rb ----------

class MapsController < ApplicationController
  def index
    restore_map
    @map = GMap.new("map_div")

    lat  = params[:lat]
    lng  = params[:lng]
    zoom = (params[:zoom] || 12).to_i
    if !lat.blank? && !lng.blank?
      @map.center_zoom_init([lat, lng], zoom)
    else
      # IPアドレスから位置情報を取得する
      @map.record_init(<<-END)
        var lat = google.loader.ClientLocation.latitude;
        var lng = google.loader.ClientLocation.longitude;
        if (lat && lng) {
          // IPアドレスから位置情報を取得できたらその位置を設定する
          map.setCenter(new GLatLng(lat,lng), #{zoom});
        } else {
          // 日本橋の座標を設定
          map.setCenter(new GLatLng(35.6840432111695,139.774460792542), #{zoom});
        }
      END
    end
    
    # 地図をコントロールする部品を設定(拡大縮小スライダーとボタン、地図と航空写真の切替ボタン)
    @map.control_init(:large_map => true,:map_type => true)
    
    # 地図が移動した時(moveイベント)、drawCenterMarkerを実行する
    @map.event_init(@map, 'move', 'function(){drawCenterMarker(map);}')
    
    # 地図の移動が終了した時(moveendイベント)、store_mapを実行する(地図の状態を保存する)
    @map.event_init(@map, 'moveend', "function(){#{remote_function_store_map};}")
    
    # 中央マーカーをグローバル変数center_markerとして定義して、表示する
    @map.overlay_global_init(center_marker, "center_marker", :local_construction => true)

    # 初めてのアクセスの場合、位置情報を修正してリロードする(地図と店舗検索の位置を合わせるため)#<------[1]
    @map.record_init("GEvent.trigger(map,'moveend');setTimeout(function(){location.reload();}, 1000);") unless !lat.blank? && !lng.blank? 
    
    # 店舗情報を取得して、マーカーを作成する#<------[2]
    @stands = GogoGs.find_in_area(params[:lng], params[:lat], 2, params[:kind])
    @stands.each_with_index do |stand, index|
      stand_marker = marker(stand)
      @map.declare_init(stand_marker, 'marker')
      @map.overlay_init(stand_marker)
      @map.record_init("markers[#{index}] = marker;")
    end
  end

  def store_map
    session[:lat]  = params[:lat]
    session[:lng]  = params[:lng]
    session[:zoom] = params[:zoom]
    session[:updated_at] = Time.now
    render :update do |page|
      # 何も更新しない
    end
  end

  private
  
    def restore_map
      params[:lat]  = session[:lat]  if params[:lat].blank?
      params[:lng]  = session[:lng]  if params[:lng].blank?
      params[:zoom] = session[:zoom] if params[:zoom].blank?
    end
    
    def center_marker
      icon   = GIcon.new(:image => "http://www.google.com/mapfiles/dd-start.png", :copy_base => GIcon::DEFAULT)
      center = @map.getCenter
      GMarker.new(center, :clickable => false, :icon => icon)
    end
    
    def remote_function_store_map
      @template.remote_function(
        :url  => {:action => 'store_map'}, 
        :with => "'lat=' + map.getCenter().lat() + '&lng=' + map.getCenter().lng() + '&zoom=' + map.getZoom()")
    end
    
    # マーカーに吹き出しウィンドウを設定して地図に表示する #<------[3]
    def marker(stand)
      @letter = (@letter.succ rescue 'A')
      icon   = GIcon.new(:image => "http://www.google.com/mapfiles/marker#{@letter}.png", :copy_base => GIcon::DEFAULT)
      latlng = [stand['Latitude'].to_f, stand['Longitude'].to_f]
      GMarker.new latlng, :icon => icon, :info_window => <<-END
#{stand['Price']} &nbsp; #{stand['Date']} &nbsp; #{stand['Rtc']} &nbsp; #{stand['Self']}<br />
        #{@template.link_to(stand['ShopName'], "http://gogo.gs/shop/#{stand['ShopCode']}.html")}<br />
        #{stand['Address']}
        <img src="http://gogo.gs/images/rally/#{stand['ShopCode']}-#{stand['Photo']}.jpg" width="120" height="90" align="top"/>
      END
    end
end
  • ビューでは、レギュラー・ハイオク・軽油を選択できるようにして、取得した店舗情報を表示している。(デフォルトはレギュラー)
<%# ---------- app/views/maps/index.html.erb ---------- %>

<div>
<%= link_to('レギュラー', :kind => '0') %> |
<%= link_to('ハイオク',  :kind => '1') %> |
<%= link_to('軽油',    :kind => '2') %>
</div>

<table style="background:#ddd">
  <tr>
    <td>
      <%= @map.div(:width => 500, :height => 300) %>
    </td>

    <td valign="top">
	  <div id="sidebar" style="overflow:auto; width:500px; height:300px;">
	  <ul>
      <% @stands.each_with_index do |stand, index| %>
        <li style="list-style-type:upper-alpha">
          <%= link_to_function "#{stand['Price']} #{stand['ShopName']}", "GEvent.trigger(markers[#{index}], 'click')" %>
        </li>
      <% end %>
      </ul>
      </div>
    </td>
  </tr>
</table>
  • 地図を確認してみると、以下のように表示された。


RESTなAPIでなくてもActiveResourceは利用できる!

# ---------- app/models/gogo_gs.rb ----------

class GogoGs < ActiveResource::Base
  HOST = 'api.gogo.gs' 
  PATH = '/v1.1/'
  APID = 'guest'
  NUM  = 26
  SPAN = 30

  self.site    = "http://#{HOST}/"
  self.timeout = 5
  self.logger  = Logger.new($stderr)   # logging。不要なら削除。
  
  def self.find_in_area(lon, lat, dist=2, kind=0)
    option = {:apid => APID, 
              :kind => kind, 
              :lon  => lon, 
              :lat  => lat, 
              :dist => dist, 
              :num  => NUM, 
              :span => SPAN}
    result = self.find(:one, :from => PATH, :params => option)
    self.newest_and_unique(result.Item)
  end
  
  # 最新の日付を残して重複する店舗は取り除く(ショップコード・日付順に並べ替えて、同じショップコードが連続する場合は価格をnilにして、取り除き、価格・日付順に並べ替える)
  def self.newest_and_unique(stands)
    stands = [stands] unless stands.is_a?(Array)
    stands.sort!     { |a, b| [a.ShopCode, b.Date] <=> [b.ShopCode, a.Date] }
    stands.inject    { |a, b| a.ShopCode == b.ShopCode ? (b.Price = nil; a;) : b }
    stands.delete_if { |a| a.Price.nil? }
    stands.sort!     { |a, b| [a.Price, b.Date] <=> [b.Price, a.Date] }
  rescue
    []
  end
end
  • 感動です!こんなに簡単にWEBサービスAPIにアクセスできるようになっていたとは...。
  • しかも、返される結果は、ハッシュでなく、ActiveRecordのようにメソッドで属性値にアクセス可能なオブジェクト。
  • だから今まで stand['Price'] とやっていたコードは、stand.PriceでOKになる。*1
  • ついでに、なぜか同じ店舗で、過去の価格情報も重複して取得できてしまう場合があるので、最新の価格情報のみにするnewest_and_uniqueメソッドも定義しておいた。
  • そんな時、stand.Priceと表現できることに幸せを感じる。書きやすいし、読みやすい。
  • コントローラーとビューも、以下のように修正した。
# ---------- app/controllers/maps_controller.rb ----------

class MapsController < ApplicationController
...(中略)...
private
...(中略)...
    def marker(stand)
      @letter = (@letter.succ rescue 'A')
      icon   = GIcon.new(:image => "http://www.google.com/mapfiles/marker#{@letter}.png", :copy_base => GIcon::DEFAULT)
      latlng = [stand.Latitude.to_f, stand.Longitude.to_f]
      GMarker.new latlng, :icon => icon, :info_window => <<-END
#{stand.Price} &nbsp; #{stand.Date} &nbsp; #{stand.Rtc} &nbsp; #{stand.Self}<br />
        #{stand.ShopName}<br />
        #{stand.Address}
        <img src="http://gogo.gs/images/rally/#{stand.ShopCode}-#{stand.Photo}.jpg" width="120" height="90" align="top"/>
      END
    end
end
<%# ---------- app/views/maps/index.html.erb ---------- %>

...(中略)...
          <%= link_to_function "#{stand.Price} #{stand.ShopName}", "GEvent.trigger(markers[#{index}], 'click')" %>
...(中略)...

マーカーに価格を表示する

  • 最後にマーカーに価格を表示したいのだが、これは地道にグラフィックエディタで数字とアイコンを合成していくしか方法が思い付かない...。
  • 100円から209円まで100個以上ののアイコンを合成して、public/images/mapfiles/marker100.png 〜 marker209.pngとして保存しておいた。
  • コントローラーとビューは以下のように変更して、GogoGsモデルの変更は無く上記直前の状態と同じ。
# ---------- app/controllers/maps_controller.rb ----------

class MapsController < ApplicationController
  def index
    restore_map
    @map = GMap.new("map_div")

    lat  = params[:lat]
    lng  = params[:lng]
    zoom = (params[:zoom] || 12).to_i
    if !lat.blank? && !lng.blank?
      @map.center_zoom_init([lat, lng], zoom)
    else
      # IPアドレスから位置情報を取得する
      @map.record_init(<<-END)
        var lat = google.loader.ClientLocation.latitude;
        var lng = google.loader.ClientLocation.longitude;
        if (lat && lng) {
          // IPアドレスから位置情報を取得できたらその位置を設定する
          map.setCenter(new GLatLng(lat,lng), #{zoom});
        } else {
          // 日本橋の座標を設定
          map.setCenter(new GLatLng(35.6840432111695,139.774460792542), #{zoom});
        }
      END
    end
    
    # 地図をコントロールする部品を設定(拡大縮小スライダーとボタン、地図と航空写真の切替ボタン)
    @map.control_init(:large_map => true,:map_type => true)
    
    # 地図が移動した時(moveイベント)、drawCenterMarkerを実行する
    @map.event_init(@map, 'move', 'function(){drawCenterMarker(map);}')
    
    # 地図の移動が終了した時(moveendイベント)、store_mapを実行する(地図の状態を保存する)
    @map.event_init(@map, 'moveend', "function(){#{remote_function_store_map};}")
    
    # 中央マーカーをグローバル変数center_markerとして定義して、表示する
    @map.overlay_global_init(center_marker, "center_marker", :local_construction => true)

    # 初めてのアクセスの場合、位置情報を修正してリロードする(地図と店舗検索の位置を合わせるため)
    @map.record_init("GEvent.trigger(map,'moveend');setTimeout(function(){location.reload();}, 1000);") unless !lat.blank? && !lng.blank?
    
    # 店舗情報を取得して、マーカーを作成する
    @stands = Gs.find_in_area(params[:lng], params[:lat], 4, params[:kind])
    @stands.each_with_index do |stand, index|
      stand_marker = marker(stand)
      @map.declare_init(stand_marker, 'marker')
      @map.overlay_init(stand_marker)
      @map.record_init("markers[#{index}] = marker;")
    end
  end

  def store_map
    session[:lat]  = params[:lat]
    session[:lng]  = params[:lng]
    session[:zoom] = params[:zoom]
    session[:updated_at] = Time.now
    render :update do |page|
      # 何も更新しない
    end
  end

  private
  
    def restore_map
      params[:lat]  = session[:lat]  if params[:lat].blank?
      params[:lng]  = session[:lng]  if params[:lng].blank?
      params[:zoom] = session[:zoom] if params[:zoom].blank?
    end
    
    def center_marker
      icon   = GIcon.new(:image => "http://www.google.com/mapfiles/dd-start.png", :copy_base => GIcon::DEFAULT)
      center = @map.getCenter
      GMarker.new(center, :clickable => false, :icon => icon)
    end
    
    def remote_function_store_map
      @template.remote_function(
        :url  => {:action => 'store_map'}, 
        :with => "'lat=' + map.getCenter().lat() + '&lng=' + map.getCenter().lng() + '&zoom=' + map.getZoom()")
    end
    
    def marker(stand)
      number = File.exist?("#{RAILS_ROOT}/public/images/mapfiles/marker#{stand.Price}.png") ? stand.Price : ""
      icon   = GIcon.new(:image => "../images/mapfiles/marker#{number}.png", :copy_base => GIcon::DEFAULT)
      latlng = [stand.Latitude.to_f, stand.Longitude.to_f]
      GMarker.new latlng, :icon => icon, :info_window => <<-END
#{stand.Price} &nbsp; #{stand.Date} &nbsp; #{stand.Rtc} &nbsp; #{stand.Self}<br />
        #{@template.link_to(stand.ShopName, "http://gogo.gs/shop/#{stand.ShopCode}.html")}<br />
        #{stand.Address}
        <img src="http://gogo.gs/images/rally/#{stand.ShopCode}-#{stand.Photo}.jpg" width="120" height="90" align="top"/>
      END
    end
end
<%# ---------- app/views/maps/index.html.erb ---------- %>

<div>
<%= link_to_unless_current('レギュラー', :action => 'index') {"<b>レギュラー</b>"} %> |
<%= link_to_unless_current('ハイオク',  :kind => '1')       {"<b>ハイオク</b>"}  %> |
<%= link_to_unless_current('軽油',    :kind => '2')       {"<b>軽油</b>"}    %>
</div>

<table style="background:#ddd">
  <tr>
    <td>
      <%= @map.div(:width => 500, :height => 300) %>
    </td>

    <td valign="top">
      <div id="sidebar" style="overflow:auto; width:500px; height:300px;">
      <ul>
      <% @stands.each_with_index do |stand, index| %>
        <li>
          <%= link_to_function "#{stand.Price} #{stand.Date.sub(/\d\d\d\d\//, '')} #{stand.ShopName}", "GEvent.trigger(markers[#{index}], 'click')" %>
        </li>
      <% end %>
      </ul>
      </div>
    </td>
  </tr>
</table>
  • 地図を確認すると、ちゃんと対応する価格のマーカーが表示された!

  • 最後にアクセスした地図の位置は保存されるので、次回のアクセスが手軽になる。

*1:メソッド名が大文字なのは、返されるxml文字列が<Price>100</Price>のようになっているため。stand.priceではエラーになる。