Merge pull request #518 from knu/refactor-map_marker

Add Event#location and refactor the map_marker partial.

Akinori MUSHA 10 jaren geleden
bovenliggende
commit
ab82683216

+ 41 - 0
app/assets/javascripts/map_marker.js.coffee

@@ -0,0 +1,41 @@
1
+window.map_marker = (map, options = {}) ->
2
+  pos = new google.maps.LatLng(options.lat, options.lng)
3
+
4
+  if options.radius > 0
5
+    new google.maps.Circle
6
+      map: map
7
+      strokeColor: '#FF0000'
8
+      strokeOpacity: 0.8
9
+      strokeWeight: 2
10
+      fillColor: '#FF0000'
11
+      fillOpacity: 0.35
12
+      center: pos
13
+      radius: options.radius
14
+  else
15
+    new google.maps.Marker
16
+      map: map
17
+      position: pos
18
+      title: 'Recorded Location'
19
+
20
+  if options.course
21
+    p1 = new LatLon(pos.lat(), pos.lng())
22
+    speed = options.speed ? 1
23
+    p2 = p1.destinationPoint(options.course, Math.max(0.2, speed) * 0.1)
24
+
25
+    lineCoordinates = [
26
+      pos
27
+      new google.maps.LatLng(p2.lat(), p2.lon())
28
+    ]
29
+
30
+    lineSymbol =
31
+      path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW
32
+
33
+    new google.maps.Polyline
34
+      map: map
35
+      path: lineCoordinates
36
+      icons: [
37
+        {
38
+          icon: lineSymbol
39
+          offset: '100%'
40
+        }
41
+      ]

+ 4 - 2
app/models/agents/user_location_agent.rb

@@ -65,8 +65,10 @@ module Agents
65 65
     private
66 66
 
67 67
     def handle_payload(payload)
68
-      if payload[:latitude].present? && payload[:longitude].present?
69
-        create_event payload: payload, lat: payload[:latitude].to_f, lng: payload[:longitude].to_f
68
+      location = Location.new(payload)
69
+
70
+      if location.present?
71
+        create_event payload: payload, location: location
70 72
       end
71 73
     end
72 74
   end

+ 43 - 1
app/models/event.rb

@@ -1,3 +1,5 @@
1
+require 'location'
2
+
1 3
 # Events are how Huginn Agents communicate and log information about the world.  Events can be emitted and received by
2 4
 # Agents.  They contain a serialized `payload` of arbitrary JSON data, as well as optional `lat`, `lng`, and `expires_at`
3 5
 # fields.
@@ -5,7 +7,7 @@ class Event < ActiveRecord::Base
5 7
   include JSONSerializedField
6 8
   include LiquidDroppable
7 9
 
8
-  attr_accessible :lat, :lng, :payload, :user_id, :user, :expires_at
10
+  attr_accessible :lat, :lng, :location, :payload, :user_id, :user, :expires_at
9 11
 
10 12
   acts_as_mappable
11 13
 
@@ -28,6 +30,42 @@ class Event < ActiveRecord::Base
28 30
     where("expires_at IS NOT NULL AND expires_at < ?", Time.now)
29 31
   }
30 32
 
33
+  scope :with_location, -> {
34
+    where.not(lat: nil).where.not(lng: nil)
35
+  }
36
+
37
+  def location
38
+    @location ||= Location.new(
39
+      # lat and lng are BigDecimal, but converted to Float by the Location class
40
+      lat: lat,
41
+      lng: lng,
42
+      radius:
43
+        begin
44
+          h = payload[:horizontal_accuracy].presence
45
+          v = payload[:vertical_accuracy].presence
46
+          if h && v
47
+            (h.to_f + v.to_f) / 2
48
+          else
49
+            (h || v || payload[:accuracy]).to_f
50
+          end
51
+        end,
52
+      course: payload[:course],
53
+      speed: payload[:speed].presence)
54
+  end
55
+
56
+  def location=(location)
57
+    case location
58
+    when nil
59
+      self.lat = self.lng = nil
60
+      return
61
+    when Location
62
+    else
63
+      location = Location.new(location)
64
+    end
65
+    self.lat, self.lng = location.lat, location.lng
66
+    location
67
+  end
68
+
31 69
   # Emit this event again, as a new Event.
32 70
   def reemit!
33 71
     agent.create_event :payload => payload, :lat => lat, :lng => lng
@@ -79,4 +117,8 @@ class EventDrop
79 117
       @object.created_at
80 118
     }
81 119
   end
120
+
121
+  def _location_
122
+    @object.location
123
+  end
82 124
 end

+ 8 - 6
app/views/agents/agent_views/user_location_agent/_show.html.erb

@@ -1,8 +1,11 @@
1
-<script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false"></script>
1
+<% content_for :head do -%>
2
+<%= javascript_include_tag "https://maps.googleapis.com/maps/api/js?sensor=false" %>
3
+<%= javascript_include_tag "map_marker" %>
4
+<% end -%>
2 5
 
3 6
 <h3>Recent Event Map</h3>
4 7
 
5
-<% events = @agent.events.where("lat IS NOT null AND lng IS NOT null").order("id desc").limit(500) %>
8
+<% events = @agent.events.with_location.order("id desc").limit(500) %>
6 9
 <% if events.length > 0 %>
7 10
   <div id="map_canvas" style="width:800px; height:800px"></div>
8 11
 
@@ -14,11 +17,10 @@
14 17
     };
15 18
 
16 19
     var map = new google.maps.Map(document.getElementById("map_canvas"), mapOptions);
20
+    <% events.each do |event| %>
21
+    map_marker(map, <%= Utils.jsonify(event.location) %>);
22
+    <% end %>
17 23
   </script>
18
-
19
-  <% events.each do |event| %>
20
-    <%= render "shared/map_marker", event: event %>
21
-  <% end %>
22 24
 <% else %>
23 25
   <p>
24 26
     No events found.

+ 6 - 3
app/views/events/show.html.erb

@@ -16,7 +16,10 @@
16 16
       </p>
17 17
 
18 18
       <% if @event.lat && @event.lng %>
19
-        <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false"></script>
19
+        <% content_for :head do -%>
20
+<%= javascript_include_tag "https://maps.googleapis.com/maps/api/js?sensor=false" %>
21
+<%= javascript_include_tag "map_marker" %>
22
+        <% end -%>
20 23
 
21 24
         <p>
22 25
           <b>Lat:</b>
@@ -36,9 +39,9 @@
36 39
           };
37 40
 
38 41
           var map = new google.maps.Map(document.getElementById("map_canvas"), mapOptions);
39
-        </script>
40 42
 
41
-        <%= render "shared/map_marker", event: @event %>
43
+          map_marker(map, <%= Utils.jsonify(@event.location) %>);
44
+        </script>
42 45
       <% end %>
43 46
 
44 47
       <br />

+ 0 - 61
app/views/shared/_map_marker.html.erb

@@ -1,61 +0,0 @@
1
-<script>
2
-  (function(map) {
3
-    <%
4
-       if event.payload[:horizontal_accuracy] && event.payload[:vertical_accuracy]
5
-         radius = (event.payload[:horizontal_accuracy].to_f + event.payload[:vertical_accuracy].to_f) / 2.0
6
-       elsif event.payload[:horizontal_accuracy]
7
-         radius = event.payload[:horizontal_accuracy].to_f
8
-       elsif event.payload[:vertical_accuracy]
9
-         radius = event.payload[:vertical_accuracy].to_f
10
-       elsif event.payload[:accuracy]
11
-         radius = event.payload[:accuracy].to_f
12
-       else
13
-         radius = 0
14
-       end
15
-    %>
16
-
17
-    var pos = new google.maps.LatLng(<%= event.lat %>, <%= event.lng %>);
18
-
19
-    <% if radius > 0 %>
20
-      var accuracyCircle = new google.maps.Circle({
21
-        strokeColor: '#FF0000',
22
-        strokeOpacity: 0.8,
23
-        strokeWeight: 2,
24
-        fillColor: '#FF0000',
25
-        fillOpacity: 0.35,
26
-        map: map,
27
-        center: pos,
28
-        radius: <%= radius %>
29
-      });
30
-    <% else %>
31
-      var marker = new google.maps.Marker({
32
-        position: pos,
33
-        map: map,
34
-        title: 'Recorded Location'
35
-      });
36
-    <% end %>
37
-
38
-
39
-    <% if event.payload[:course] && event.payload[:course].to_f > -1 %>
40
-      var p1 = new LatLon(pos.lat(), pos.lng());
41
-      var p2 = p1.destinationPoint(<%= event.payload[:course].to_f %>, <%= [0.2, (event.payload[:speed] || 1).to_f].max * 0.1 %>);
42
-
43
-      var lineCoordinates = [ pos, new google.maps.LatLng(p2.lat(), p2.lon()) ];
44
-
45
-      var lineSymbol = {
46
-        path:google.maps.SymbolPath.FORWARD_CLOSED_ARROW
47
-      };
48
-
49
-      var line = new google.maps.Polyline({
50
-        path: lineCoordinates,
51
-        icons: [
52
-          {
53
-            icon: lineSymbol,
54
-            offset: '100%'
55
-          }
56
-        ],
57
-        map: map
58
-      });
59
-    <% end %>
60
-  })(map);
61
-</script>

+ 1 - 1
config/environments/production.rb

@@ -61,7 +61,7 @@ Huginn::Application.configure do
61 61
   end
62 62
 
63 63
   # Precompile additional assets (application.js.coffee.erb, application.css, and all non-JS/CSS are already added)
64
-  config.assets.precompile += %w( diagram.js graphing.js user_credentials.js )
64
+  config.assets.precompile += %w( diagram.js graphing.js map_marker.js user_credentials.js )
65 65
 
66 66
   # Ignore bad email addresses and do not raise email delivery errors.
67 67
   # Set this to true and configure the email server for immediate delivery to raise delivery errors.

+ 110 - 0
lib/location.rb

@@ -0,0 +1,110 @@
1
+require 'liquid'
2
+
3
+Location = Struct.new(:lat, :lng, :radius, :speed, :course)
4
+
5
+class Location
6
+  include LiquidDroppable
7
+
8
+  protected :[]=
9
+
10
+  def initialize(data = {})
11
+    super()
12
+
13
+    case data
14
+    when Array
15
+      raise ArgumentError, 'unsupported location data' unless data.size == 2
16
+      self.lat, self.lng = data
17
+    when Hash, Location
18
+      data.each { |key, value|
19
+        case key.to_sym
20
+        when :lat, :latitude
21
+          self.lat = value
22
+        when :lng, :longitude
23
+          self.lng = value
24
+        when :radius
25
+          self.radius = value
26
+        when :speed
27
+          self.speed = value
28
+        when :course
29
+          self.course = value
30
+        end
31
+      }
32
+    else
33
+      raise ArgumentError, 'unsupported location data'
34
+    end
35
+
36
+    yield self if block_given?
37
+  end
38
+
39
+  def lat=(value)
40
+    self[:lat] = floatify(value) { |f|
41
+      if f.abs <= 90
42
+        f
43
+      else
44
+        raise ArgumentError, 'out of bounds'
45
+      end
46
+    }
47
+  end
48
+
49
+  alias latitude  lat
50
+  alias latitude= lat=
51
+
52
+  def lng=(value)
53
+    self[:lng] = floatify(value) { |f|
54
+      if f.abs <= 180
55
+        f
56
+      else
57
+        raise ArgumentError, 'out of bounds'
58
+      end
59
+    }
60
+  end
61
+
62
+  alias longitude  lng
63
+  alias longitude= lng=
64
+
65
+  def radius=(value)
66
+    self[:radius] = floatify(value) { |f| f if f >= 0 }
67
+  end
68
+
69
+  def speed=(value)
70
+    self[:speed] = floatify(value) { |f| f if f >= 0 }
71
+  end
72
+
73
+  def course=(value)
74
+    self[:course] = floatify(value) { |f| f if (0..360).cover?(f) }
75
+  end
76
+
77
+  def present?
78
+    lat && lng
79
+  end
80
+
81
+  def empty?
82
+    !present?
83
+  end
84
+
85
+  private
86
+
87
+  def floatify(value)
88
+    case value
89
+    when nil, ''
90
+      return nil
91
+    else
92
+      float = Float(value)
93
+      if block_given?
94
+        yield(float)
95
+      else
96
+        float
97
+      end
98
+    end
99
+  end
100
+end
101
+
102
+class LocationDrop
103
+  KEYS = Location.members.map(&:to_s).concat(%w[latitude longitude])
104
+
105
+  def before_method(key)
106
+    if KEYS.include?(key)
107
+      @object.__send__(key)
108
+    end
109
+  end
110
+end

+ 68 - 0
spec/lib/location_spec.rb

@@ -0,0 +1,68 @@
1
+require 'spec_helper'
2
+
3
+describe Location do
4
+  let(:location) {
5
+    Location.new(
6
+      lat: BigDecimal.new('2.0'),
7
+      lng: BigDecimal.new('3.0'),
8
+      radius: 300,
9
+      speed: 2,
10
+      course: 30)
11
+  }
12
+
13
+  it "converts values to Float" do
14
+    expect(location.lat).to be_a Float
15
+    expect(location.lat).to eq 2.0
16
+    expect(location.lng).to be_a Float
17
+    expect(location.lng).to eq 3.0
18
+    expect(location.radius).to be_a Float
19
+    expect(location.radius).to eq 300.0
20
+    expect(location.speed).to be_a Float
21
+    expect(location.speed).to eq 2.0
22
+    expect(location.course).to be_a Float
23
+    expect(location.course).to eq 30.0
24
+  end
25
+
26
+  it "provides hash-style access to its properties with both symbol and string keys" do
27
+    expect(location[:lat]).to be_a Float
28
+    expect(location[:lat]).to eq 2.0
29
+    expect(location['lat']).to be_a Float
30
+    expect(location['lat']).to eq 2.0
31
+  end
32
+
33
+  it "does not allow hash-style assignment" do
34
+    expect {
35
+      location[:lat] = 2.0
36
+    }.to raise_error
37
+  end
38
+
39
+  it "ignores invalid values" do
40
+    location2 = Location.new(
41
+      lat: 2,
42
+      lng: 3,
43
+      radius: -1,
44
+      speed: -1,
45
+      course: -1)
46
+    expect(location2.radius).to be_nil
47
+    expect(location2.speed).to be_nil
48
+    expect(location2.course).to be_nil
49
+  end
50
+
51
+  it "considers a location empty if either latitude or longitude is missing" do
52
+    expect(Location.new.empty?).to be_truthy
53
+    expect(Location.new(lat: 2, radius: 1).present?).to be_falsy
54
+    expect(Location.new(lng: 3, radius: 1).present?).to be_falsy
55
+  end
56
+
57
+  it "is droppable" do
58
+    {
59
+      '{{location.lat}}' => '2.0',
60
+      '{{location.latitude}}' => '2.0',
61
+      '{{location.lng}}' => '3.0',
62
+      '{{location.longitude}}' => '3.0',
63
+    }.each { |template, result|
64
+      expect(Liquid::Template.parse(template).render('location' => location.to_liquid)).to eq(result),
65
+        "expected #{template.inspect} to expand to #{result.inspect}"
66
+    }
67
+  end
68
+end

+ 51 - 0
spec/models/event_spec.rb

@@ -1,6 +1,50 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe Event do
4
+  describe ".with_location" do
5
+    it "selects events with location" do
6
+      event = events(:bob_website_agent_event)
7
+      event.lat = 2
8
+      event.lng = 3
9
+      event.save!
10
+      Event.with_location.pluck(:id).should == [event.id]
11
+
12
+      event.lat = nil
13
+      event.save!
14
+      Event.with_location.should be_empty
15
+    end
16
+  end
17
+
18
+  describe "#location" do
19
+    it "returns a default hash when an event does not have a location" do
20
+      event = events(:bob_website_agent_event)
21
+      event.location.should == Location.new(
22
+        lat: nil,
23
+        lng: nil,
24
+        radius: 0.0,
25
+        speed: nil,
26
+        course: nil)
27
+    end
28
+
29
+    it "returns a hash containing location information" do
30
+      event = events(:bob_website_agent_event)
31
+      event.lat = 2
32
+      event.lng = 3
33
+      event.payload = {
34
+        radius: 300,
35
+        speed: 0.5,
36
+        course: 90.0,
37
+      }
38
+      event.save!
39
+      event.location.should == Location.new(
40
+        lat: 2.0,
41
+        lng: 3.0,
42
+        radius: 0.0,
43
+        speed: 0.5,
44
+        course: 90.0)
45
+    end
46
+  end
47
+
4 48
   describe "#reemit" do
5 49
     it "creates a new event identical to itself" do
6 50
       events(:bob_website_agent_event).lat = 2
@@ -130,6 +174,8 @@ describe EventDrop do
130 174
       'title' => 'some title',
131 175
       'url' => 'http://some.site.example.org/',
132 176
     }
177
+    @event.lat = 2
178
+    @event.lng = 3
133 179
     @event.save!
134 180
   end
135 181
 
@@ -166,4 +212,9 @@ describe EventDrop do
166 212
     t = '{{created_at | date:"%FT%T%z" }}'
167 213
     interpolate(t, @event).should eq(@event.created_at.strftime("%FT%T%z"))
168 214
   end
215
+
216
+  it 'should have _location_' do
217
+    t = '{{_location_.lat}},{{_location_.lng}}'
218
+    interpolate(t, @event).should eq("2.0,3.0")
219
+  end
169 220
 end