diff --git a/app/assets/javascripts/map_marker.js.coffee b/app/assets/javascripts/map_marker.js.coffee new file mode 100644 index 00000000..146a805c --- /dev/null +++ b/app/assets/javascripts/map_marker.js.coffee @@ -0,0 +1,41 @@ +window.map_marker = (map, options = {}) -> + pos = new google.maps.LatLng(options.lat, options.lng) + + if options.radius > 0 + new google.maps.Circle + map: map + strokeColor: '#FF0000' + strokeOpacity: 0.8 + strokeWeight: 2 + fillColor: '#FF0000' + fillOpacity: 0.35 + center: pos + radius: options.radius + else + new google.maps.Marker + map: map + position: pos + title: 'Recorded Location' + + if options.course + p1 = new LatLon(pos.lat(), pos.lng()) + speed = options.speed ? 1 + p2 = p1.destinationPoint(options.course, Math.max(0.2, speed) * 0.1) + + lineCoordinates = [ + pos + new google.maps.LatLng(p2.lat(), p2.lon()) + ] + + lineSymbol = + path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW + + new google.maps.Polyline + map: map + path: lineCoordinates + icons: [ + { + icon: lineSymbol + offset: '100%' + } + ] diff --git a/app/models/agents/user_location_agent.rb b/app/models/agents/user_location_agent.rb index 2575e800..e3164131 100644 --- a/app/models/agents/user_location_agent.rb +++ b/app/models/agents/user_location_agent.rb @@ -65,8 +65,10 @@ module Agents private def handle_payload(payload) - if payload[:latitude].present? && payload[:longitude].present? - create_event payload: payload, lat: payload[:latitude].to_f, lng: payload[:longitude].to_f + location = Location.new(payload) + + if location.present? + create_event payload: payload, location: location end end end diff --git a/app/models/event.rb b/app/models/event.rb index f534ef05..321fc233 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,3 +1,5 @@ +require 'location' + # Events are how Huginn Agents communicate and log information about the world. Events can be emitted and received by # Agents. They contain a serialized `payload` of arbitrary JSON data, as well as optional `lat`, `lng`, and `expires_at` # fields. @@ -5,7 +7,7 @@ class Event < ActiveRecord::Base include JSONSerializedField include LiquidDroppable - attr_accessible :lat, :lng, :payload, :user_id, :user, :expires_at + attr_accessible :lat, :lng, :location, :payload, :user_id, :user, :expires_at acts_as_mappable @@ -28,6 +30,42 @@ class Event < ActiveRecord::Base where("expires_at IS NOT NULL AND expires_at < ?", Time.now) } + scope :with_location, -> { + where.not(lat: nil).where.not(lng: nil) + } + + def location + @location ||= Location.new( + # lat and lng are BigDecimal, but converted to Float by the Location class + lat: lat, + lng: lng, + radius: + begin + h = payload[:horizontal_accuracy].presence + v = payload[:vertical_accuracy].presence + if h && v + (h.to_f + v.to_f) / 2 + else + (h || v || payload[:accuracy]).to_f + end + end, + course: payload[:course], + speed: payload[:speed].presence) + end + + def location=(location) + case location + when nil + self.lat = self.lng = nil + return + when Location + else + location = Location.new(location) + end + self.lat, self.lng = location.lat, location.lng + location + end + # Emit this event again, as a new Event. def reemit! agent.create_event :payload => payload, :lat => lat, :lng => lng @@ -79,4 +117,8 @@ class EventDrop @object.created_at } end + + def _location_ + @object.location + end end diff --git a/app/views/agents/agent_views/user_location_agent/_show.html.erb b/app/views/agents/agent_views/user_location_agent/_show.html.erb index f1756ad8..37555cd9 100644 --- a/app/views/agents/agent_views/user_location_agent/_show.html.erb +++ b/app/views/agents/agent_views/user_location_agent/_show.html.erb @@ -1,8 +1,11 @@ - +<% content_for :head do -%> +<%= javascript_include_tag "https://maps.googleapis.com/maps/api/js?sensor=false" %> +<%= javascript_include_tag "map_marker" %> +<% end -%>

Recent Event Map

-<% events = @agent.events.where("lat IS NOT null AND lng IS NOT null").order("id desc").limit(500) %> +<% events = @agent.events.with_location.order("id desc").limit(500) %> <% if events.length > 0 %>
@@ -14,11 +17,10 @@ }; var map = new google.maps.Map(document.getElementById("map_canvas"), mapOptions); + <% events.each do |event| %> + map_marker(map, <%= Utils.jsonify(event.location) %>); + <% end %> - - <% events.each do |event| %> - <%= render "shared/map_marker", event: event %> - <% end %> <% else %>

No events found. diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 8bfa52ce..636e8254 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -16,7 +16,10 @@

<% if @event.lat && @event.lng %> - + <% content_for :head do -%> +<%= javascript_include_tag "https://maps.googleapis.com/maps/api/js?sensor=false" %> +<%= javascript_include_tag "map_marker" %> + <% end -%>

Lat: @@ -36,9 +39,9 @@ }; var map = new google.maps.Map(document.getElementById("map_canvas"), mapOptions); - - <%= render "shared/map_marker", event: @event %> + map_marker(map, <%= Utils.jsonify(@event.location) %>); + <% end %>
diff --git a/app/views/shared/_map_marker.html.erb b/app/views/shared/_map_marker.html.erb deleted file mode 100644 index e288a591..00000000 --- a/app/views/shared/_map_marker.html.erb +++ /dev/null @@ -1,61 +0,0 @@ - \ No newline at end of file diff --git a/config/environments/production.rb b/config/environments/production.rb index a43a0775..51d0dc99 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -61,7 +61,7 @@ Huginn::Application.configure do end # Precompile additional assets (application.js.coffee.erb, application.css, and all non-JS/CSS are already added) - config.assets.precompile += %w( diagram.js graphing.js user_credentials.js ) + config.assets.precompile += %w( diagram.js graphing.js map_marker.js user_credentials.js ) # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. diff --git a/lib/location.rb b/lib/location.rb new file mode 100644 index 00000000..5e64cdcf --- /dev/null +++ b/lib/location.rb @@ -0,0 +1,110 @@ +require 'liquid' + +Location = Struct.new(:lat, :lng, :radius, :speed, :course) + +class Location + include LiquidDroppable + + protected :[]= + + def initialize(data = {}) + super() + + case data + when Array + raise ArgumentError, 'unsupported location data' unless data.size == 2 + self.lat, self.lng = data + when Hash, Location + data.each { |key, value| + case key.to_sym + when :lat, :latitude + self.lat = value + when :lng, :longitude + self.lng = value + when :radius + self.radius = value + when :speed + self.speed = value + when :course + self.course = value + end + } + else + raise ArgumentError, 'unsupported location data' + end + + yield self if block_given? + end + + def lat=(value) + self[:lat] = floatify(value) { |f| + if f.abs <= 90 + f + else + raise ArgumentError, 'out of bounds' + end + } + end + + alias latitude lat + alias latitude= lat= + + def lng=(value) + self[:lng] = floatify(value) { |f| + if f.abs <= 180 + f + else + raise ArgumentError, 'out of bounds' + end + } + end + + alias longitude lng + alias longitude= lng= + + def radius=(value) + self[:radius] = floatify(value) { |f| f if f >= 0 } + end + + def speed=(value) + self[:speed] = floatify(value) { |f| f if f >= 0 } + end + + def course=(value) + self[:course] = floatify(value) { |f| f if (0..360).cover?(f) } + end + + def present? + lat && lng + end + + def empty? + !present? + end + + private + + def floatify(value) + case value + when nil, '' + return nil + else + float = Float(value) + if block_given? + yield(float) + else + float + end + end + end +end + +class LocationDrop + KEYS = Location.members.map(&:to_s).concat(%w[latitude longitude]) + + def before_method(key) + if KEYS.include?(key) + @object.__send__(key) + end + end +end diff --git a/spec/lib/location_spec.rb b/spec/lib/location_spec.rb new file mode 100644 index 00000000..ea42fa40 --- /dev/null +++ b/spec/lib/location_spec.rb @@ -0,0 +1,68 @@ +require 'spec_helper' + +describe Location do + let(:location) { + Location.new( + lat: BigDecimal.new('2.0'), + lng: BigDecimal.new('3.0'), + radius: 300, + speed: 2, + course: 30) + } + + it "converts values to Float" do + expect(location.lat).to be_a Float + expect(location.lat).to eq 2.0 + expect(location.lng).to be_a Float + expect(location.lng).to eq 3.0 + expect(location.radius).to be_a Float + expect(location.radius).to eq 300.0 + expect(location.speed).to be_a Float + expect(location.speed).to eq 2.0 + expect(location.course).to be_a Float + expect(location.course).to eq 30.0 + end + + it "provides hash-style access to its properties with both symbol and string keys" do + expect(location[:lat]).to be_a Float + expect(location[:lat]).to eq 2.0 + expect(location['lat']).to be_a Float + expect(location['lat']).to eq 2.0 + end + + it "does not allow hash-style assignment" do + expect { + location[:lat] = 2.0 + }.to raise_error + end + + it "ignores invalid values" do + location2 = Location.new( + lat: 2, + lng: 3, + radius: -1, + speed: -1, + course: -1) + expect(location2.radius).to be_nil + expect(location2.speed).to be_nil + expect(location2.course).to be_nil + end + + it "considers a location empty if either latitude or longitude is missing" do + expect(Location.new.empty?).to be_truthy + expect(Location.new(lat: 2, radius: 1).present?).to be_falsy + expect(Location.new(lng: 3, radius: 1).present?).to be_falsy + end + + it "is droppable" do + { + '{{location.lat}}' => '2.0', + '{{location.latitude}}' => '2.0', + '{{location.lng}}' => '3.0', + '{{location.longitude}}' => '3.0', + }.each { |template, result| + expect(Liquid::Template.parse(template).render('location' => location.to_liquid)).to eq(result), + "expected #{template.inspect} to expand to #{result.inspect}" + } + end +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index c2e40c03..e7fd4bb8 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -1,6 +1,50 @@ require 'spec_helper' describe Event do + describe ".with_location" do + it "selects events with location" do + event = events(:bob_website_agent_event) + event.lat = 2 + event.lng = 3 + event.save! + Event.with_location.pluck(:id).should == [event.id] + + event.lat = nil + event.save! + Event.with_location.should be_empty + end + end + + describe "#location" do + it "returns a default hash when an event does not have a location" do + event = events(:bob_website_agent_event) + event.location.should == Location.new( + lat: nil, + lng: nil, + radius: 0.0, + speed: nil, + course: nil) + end + + it "returns a hash containing location information" do + event = events(:bob_website_agent_event) + event.lat = 2 + event.lng = 3 + event.payload = { + radius: 300, + speed: 0.5, + course: 90.0, + } + event.save! + event.location.should == Location.new( + lat: 2.0, + lng: 3.0, + radius: 0.0, + speed: 0.5, + course: 90.0) + end + end + describe "#reemit" do it "creates a new event identical to itself" do events(:bob_website_agent_event).lat = 2 @@ -130,6 +174,8 @@ describe EventDrop do 'title' => 'some title', 'url' => 'http://some.site.example.org/', } + @event.lat = 2 + @event.lng = 3 @event.save! end @@ -166,4 +212,9 @@ describe EventDrop do t = '{{created_at | date:"%FT%T%z" }}' interpolate(t, @event).should eq(@event.created_at.strftime("%FT%T%z")) end + + it 'should have _location_' do + t = '{{_location_.lat}},{{_location_.lng}}' + interpolate(t, @event).should eq("2.0,3.0") + end end