geocoder 0.1.1 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of geocoder might be problematic. Click here for more details.
- data/CHANGELOG.rdoc +96 -0
- data/LICENSE +1 -1
- data/README.rdoc +215 -0
- data/Rakefile +46 -43
- data/lib/geocoder.rb +17 -321
- data/lib/geocoder/active_record.rb +235 -0
- data/lib/geocoder/calculations.rb +94 -0
- data/lib/geocoder/configuration.rb +8 -0
- data/lib/geocoder/lookup.rb +90 -0
- data/lib/geocoder/railtie.rb +68 -0
- data/lib/geocoder/result.rb +42 -0
- data/lib/tasks/geocoder.rake +15 -0
- data/test/geocoder_test.rb +36 -0
- data/test/test_helper.rb +97 -0
- metadata +68 -51
- data/CHANGELOG +0 -10
- data/README +0 -122
- data/TODO +0 -10
- data/bin/geocode +0 -4
- data/test/mocks/csv?address=2038+damen+ave+chicago+il +0 -2
- data/test/mocks/csv?address=2125+w+north+ave+chicago+il +0 -1
- data/test/mocks/csv?address=donotleaveitisnotreal +0 -1
- data/test/mocks/geocode?appid=YahooDemo&location= +0 -4
- data/test/mocks/geocode?appid=YahooDemo&location=2038+damen+ave+chicago+il +0 -2
- data/test/mocks/geocode?appid=YahooDemo&location=2125+w+north+ave+chicago+il +0 -2
- data/test/mocks/geocode?appid=YahooDemo&location=donotleaveitisnotreal +0 -4
- data/test/mocks/http.rb +0 -19
- data/test/sample.xml +0 -2
- data/test/tc_geocoderus.rb +0 -105
- data/test/tc_yahoo.rb +0 -110
- data/test/ts_geocoder.rb +0 -4
@@ -0,0 +1,235 @@
|
|
1
|
+
##
|
2
|
+
# Add geocoding functionality to any ActiveRecord object.
|
3
|
+
#
|
4
|
+
module Geocoder
|
5
|
+
module ActiveRecord
|
6
|
+
|
7
|
+
##
|
8
|
+
# Implementation of 'included' hook method.
|
9
|
+
#
|
10
|
+
def self.included(base)
|
11
|
+
base.extend ClassMethods
|
12
|
+
base.class_eval do
|
13
|
+
|
14
|
+
# scope: geocoded objects
|
15
|
+
scope :geocoded,
|
16
|
+
:conditions => "#{geocoder_options[:latitude]} IS NOT NULL " +
|
17
|
+
"AND #{geocoder_options[:longitude]} IS NOT NULL"
|
18
|
+
|
19
|
+
# scope: not-geocoded objects
|
20
|
+
scope :not_geocoded,
|
21
|
+
:conditions => "#{geocoder_options[:latitude]} IS NULL " +
|
22
|
+
"OR #{geocoder_options[:longitude]} IS NULL"
|
23
|
+
|
24
|
+
##
|
25
|
+
# Find all objects within a radius (in miles) of the given location
|
26
|
+
# (address string). Location (the first argument) may be either a string
|
27
|
+
# to geocode or an array of coordinates (<tt>[lat,long]</tt>).
|
28
|
+
#
|
29
|
+
scope :near, lambda{ |location, *args|
|
30
|
+
latitude, longitude = location.is_a?(Array) ?
|
31
|
+
location : Geocoder::Lookup.coordinates(location)
|
32
|
+
if latitude and longitude
|
33
|
+
near_scope_options(latitude, longitude, *args)
|
34
|
+
else
|
35
|
+
{}
|
36
|
+
end
|
37
|
+
}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
##
|
42
|
+
# Methods which will be class methods of the including class.
|
43
|
+
#
|
44
|
+
module ClassMethods
|
45
|
+
|
46
|
+
##
|
47
|
+
# Get options hash suitable for passing to ActiveRecord.find to get
|
48
|
+
# records within a radius (in miles) of the given point.
|
49
|
+
# Options hash may include:
|
50
|
+
#
|
51
|
+
# +units+ :: <tt>:mi</tt> (default) or <tt>:km</tt>
|
52
|
+
# +exclude+ :: an object to exclude (used by the #nearbys method)
|
53
|
+
# +order+ :: column(s) for ORDER BY SQL clause
|
54
|
+
# +limit+ :: number of records to return (for LIMIT SQL clause)
|
55
|
+
# +offset+ :: number of records to skip (for OFFSET SQL clause)
|
56
|
+
# +select+ :: string with the SELECT SQL fragment (e.g. “id, name”)
|
57
|
+
#
|
58
|
+
def near_scope_options(latitude, longitude, radius = 20, options = {})
|
59
|
+
radius *= Geocoder::Calculations.km_in_mi if options[:units] == :km
|
60
|
+
if ::ActiveRecord::Base.connection.adapter_name == "SQLite"
|
61
|
+
approx_near_scope_options(latitude, longitude, radius, options)
|
62
|
+
else
|
63
|
+
full_near_scope_options(latitude, longitude, radius, options)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
private # ----------------------------------------------------------------
|
69
|
+
|
70
|
+
##
|
71
|
+
# Scope options hash for use with a database that supports POWER(),
|
72
|
+
# SQRT(), PI(), and trigonometric functions (SIN(), COS(), and ASIN()).
|
73
|
+
#
|
74
|
+
# Taken from the excellent tutorial at:
|
75
|
+
# http://www.scribd.com/doc/2569355/Geo-Distance-Search-with-MySQL
|
76
|
+
#
|
77
|
+
def full_near_scope_options(latitude, longitude, radius, options)
|
78
|
+
lat_attr = geocoder_options[:latitude]
|
79
|
+
lon_attr = geocoder_options[:longitude]
|
80
|
+
distance = "3956 * 2 * ASIN(SQRT(" +
|
81
|
+
"POWER(SIN((#{latitude} - #{lat_attr}) * " +
|
82
|
+
"PI() / 180 / 2), 2) + COS(#{latitude} * PI()/180) * " +
|
83
|
+
"COS(#{lat_attr} * PI() / 180) * " +
|
84
|
+
"POWER(SIN((#{longitude} - #{lon_attr}) * " +
|
85
|
+
"PI() / 180 / 2), 2) ))"
|
86
|
+
options[:order] ||= "#{distance} ASC"
|
87
|
+
default_near_scope_options(latitude, longitude, radius, options).merge(
|
88
|
+
:select => "#{options[:select] || '*'}, #{distance} AS distance",
|
89
|
+
:having => "#{distance} <= #{radius}"
|
90
|
+
)
|
91
|
+
end
|
92
|
+
|
93
|
+
##
|
94
|
+
# Scope options hash for use with a database without trigonometric
|
95
|
+
# functions, like SQLite. Approach is to find objects within a square
|
96
|
+
# rather than a circle, so results are very approximate (will include
|
97
|
+
# objects outside the given radius).
|
98
|
+
#
|
99
|
+
def approx_near_scope_options(latitude, longitude, radius, options)
|
100
|
+
default_near_scope_options(latitude, longitude, radius, options).merge(
|
101
|
+
:select => options[:select] || nil
|
102
|
+
)
|
103
|
+
end
|
104
|
+
|
105
|
+
##
|
106
|
+
# Options used for any near-like scope.
|
107
|
+
#
|
108
|
+
def default_near_scope_options(latitude, longitude, radius, options)
|
109
|
+
lat_attr = geocoder_options[:latitude]
|
110
|
+
lon_attr = geocoder_options[:longitude]
|
111
|
+
conditions = \
|
112
|
+
["#{lat_attr} BETWEEN ? AND ? AND #{lon_attr} BETWEEN ? AND ?"] +
|
113
|
+
coordinate_bounds(latitude, longitude, radius)
|
114
|
+
if obj = options[:exclude]
|
115
|
+
conditions[0] << " AND id != ?"
|
116
|
+
conditions << obj.id
|
117
|
+
end
|
118
|
+
{
|
119
|
+
:group => columns.map{ |c| "#{table_name}.#{c.name}" }.join(','),
|
120
|
+
:order => options[:order],
|
121
|
+
:limit => options[:limit],
|
122
|
+
:offset => options[:offset],
|
123
|
+
:conditions => conditions
|
124
|
+
}
|
125
|
+
end
|
126
|
+
|
127
|
+
##
|
128
|
+
# Get the rough high/low lat/long bounds for a geographic point and
|
129
|
+
# radius. Returns an array: <tt>[lat_lo, lat_hi, lon_lo, lon_hi]</tt>.
|
130
|
+
# Used to constrain search to a (radius x radius) square.
|
131
|
+
#
|
132
|
+
def coordinate_bounds(latitude, longitude, radius)
|
133
|
+
radius = radius.to_f
|
134
|
+
factor = (Math::cos(latitude * Math::PI / 180.0) * 69.0).abs
|
135
|
+
[
|
136
|
+
latitude - (radius / 69.0),
|
137
|
+
latitude + (radius / 69.0),
|
138
|
+
longitude - (radius / factor),
|
139
|
+
longitude + (radius / factor)
|
140
|
+
]
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
##
|
145
|
+
# Read the coordinates [lat,lon] of an object. This is not great but it
|
146
|
+
# seems cleaner than polluting the instance method namespace.
|
147
|
+
#
|
148
|
+
def read_coordinates
|
149
|
+
[:latitude, :longitude].map{ |i| send self.class.geocoder_options[i] }
|
150
|
+
end
|
151
|
+
|
152
|
+
##
|
153
|
+
# Is this object geocoded? (Does it have latitude and longitude?)
|
154
|
+
#
|
155
|
+
def geocoded?
|
156
|
+
read_coordinates.compact.size > 0
|
157
|
+
end
|
158
|
+
|
159
|
+
##
|
160
|
+
# Calculate the distance from the object to a point (lat,lon).
|
161
|
+
#
|
162
|
+
# <tt>:units</tt> :: <tt>:mi</tt> (default) or <tt>:km</tt>
|
163
|
+
#
|
164
|
+
def distance_to(lat, lon, units = :mi)
|
165
|
+
return nil unless geocoded?
|
166
|
+
mylat,mylon = read_coordinates
|
167
|
+
Geocoder::Calculations.distance_between(mylat, mylon, lat, lon, :units => units)
|
168
|
+
end
|
169
|
+
|
170
|
+
##
|
171
|
+
# Get other geocoded objects within a given radius.
|
172
|
+
#
|
173
|
+
# <tt>:units</tt> :: <tt>:mi</tt> (default) or <tt>:km</tt>
|
174
|
+
#
|
175
|
+
def nearbys(radius = 20, units = :mi)
|
176
|
+
return [] unless geocoded?
|
177
|
+
options = {:exclude => self, :units => units}
|
178
|
+
self.class.near(read_coordinates, radius, options)
|
179
|
+
end
|
180
|
+
|
181
|
+
##
|
182
|
+
# Fetch coordinates and assign +latitude+ and +longitude+. Also returns
|
183
|
+
# coordinates as an array: <tt>[lat, lon]</tt>.
|
184
|
+
#
|
185
|
+
def fetch_coordinates(save = false)
|
186
|
+
address_method = self.class.geocoder_options[:user_address]
|
187
|
+
unless address_method.is_a? Symbol
|
188
|
+
raise Geocoder::ConfigurationError,
|
189
|
+
"You are attempting to fetch coordinates but have not specified " +
|
190
|
+
"a method which provides an address for the object."
|
191
|
+
end
|
192
|
+
coords = Geocoder::Lookup.coordinates(send(address_method))
|
193
|
+
unless coords.blank?
|
194
|
+
method = (save ? "update" : "write") + "_attribute"
|
195
|
+
send method, self.class.geocoder_options[:latitude], coords[0]
|
196
|
+
send method, self.class.geocoder_options[:longitude], coords[1]
|
197
|
+
end
|
198
|
+
coords
|
199
|
+
end
|
200
|
+
|
201
|
+
##
|
202
|
+
# Fetch coordinates and update (save) +latitude+ and +longitude+ data.
|
203
|
+
#
|
204
|
+
def fetch_coordinates!
|
205
|
+
fetch_coordinates(true)
|
206
|
+
end
|
207
|
+
|
208
|
+
##
|
209
|
+
# Fetch address and assign +address+ attribute. Also returns
|
210
|
+
# address as a string.
|
211
|
+
#
|
212
|
+
def fetch_address(save = false)
|
213
|
+
lat_attr = self.class.geocoder_options[:latitude]
|
214
|
+
lon_attr = self.class.geocoder_options[:longitude]
|
215
|
+
unless lat_attr.is_a?(Symbol) and lon_attr.is_a?(Symbol)
|
216
|
+
raise Geocoder::ConfigurationError,
|
217
|
+
"You are attempting to fetch an address but have not specified " +
|
218
|
+
"attributes which provide coordinates for the object."
|
219
|
+
end
|
220
|
+
address = Geocoder::Lookup.address(send(lat_attr), send(lon_attr))
|
221
|
+
unless address.blank?
|
222
|
+
method = (save ? "update" : "write") + "_attribute"
|
223
|
+
send method, self.class.geocoder_options[:fetched_address], address
|
224
|
+
end
|
225
|
+
address
|
226
|
+
end
|
227
|
+
|
228
|
+
##
|
229
|
+
# Fetch address and update (save) +address+ data.
|
230
|
+
#
|
231
|
+
def fetch_address!
|
232
|
+
fetch_address(true)
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
module Geocoder
|
2
|
+
module Calculations
|
3
|
+
extend self
|
4
|
+
|
5
|
+
##
|
6
|
+
# Calculate the distance between two points on Earth (Haversine formula).
|
7
|
+
# Takes two sets of coordinates and an options hash:
|
8
|
+
#
|
9
|
+
# <tt>:units</tt> :: <tt>:mi</tt> (default) or <tt>:km</tt>
|
10
|
+
#
|
11
|
+
def distance_between(lat1, lon1, lat2, lon2, options = {})
|
12
|
+
|
13
|
+
# set default options
|
14
|
+
options[:units] ||= :mi
|
15
|
+
|
16
|
+
# define conversion factors
|
17
|
+
conversions = { :mi => 3956, :km => 6371 }
|
18
|
+
|
19
|
+
# convert degrees to radians
|
20
|
+
lat1 = to_radians(lat1)
|
21
|
+
lon1 = to_radians(lon1)
|
22
|
+
lat2 = to_radians(lat2)
|
23
|
+
lon2 = to_radians(lon2)
|
24
|
+
|
25
|
+
# compute distances
|
26
|
+
dlat = (lat1 - lat2).abs
|
27
|
+
dlon = (lon1 - lon2).abs
|
28
|
+
|
29
|
+
a = (Math.sin(dlat / 2))**2 + Math.cos(lat1) *
|
30
|
+
(Math.sin(dlon / 2))**2 * Math.cos(lat2)
|
31
|
+
c = 2 * Math.atan2( Math.sqrt(a), Math.sqrt(1-a))
|
32
|
+
c * conversions[options[:units]]
|
33
|
+
end
|
34
|
+
|
35
|
+
##
|
36
|
+
# Compute the geographic center (aka geographic midpoint, center of
|
37
|
+
# gravity) for an array of geocoded objects and/or [lat,lon] arrays
|
38
|
+
# (can be mixed). Any objects missing coordinates are ignored. Follows
|
39
|
+
# the procedure documented at http://www.geomidpoint.com/calculation.html.
|
40
|
+
#
|
41
|
+
def geographic_center(points)
|
42
|
+
|
43
|
+
# convert objects to [lat,lon] arrays and remove nils
|
44
|
+
points = points.map{ |p|
|
45
|
+
p.is_a?(Array) ? p : (p.geocoded?? p.read_coordinates : nil)
|
46
|
+
}.compact
|
47
|
+
|
48
|
+
# convert degrees to radians
|
49
|
+
points.map!{ |p| [to_radians(p[0]), to_radians(p[1])] }
|
50
|
+
|
51
|
+
# convert to Cartesian coordinates
|
52
|
+
x = []; y = []; z = []
|
53
|
+
points.each do |p|
|
54
|
+
x << Math.cos(p[0]) * Math.cos(p[1])
|
55
|
+
y << Math.cos(p[0]) * Math.sin(p[1])
|
56
|
+
z << Math.sin(p[0])
|
57
|
+
end
|
58
|
+
|
59
|
+
# compute average coordinate values
|
60
|
+
xa, ya, za = [x,y,z].map do |c|
|
61
|
+
c.inject(0){ |tot,i| tot += i } / c.size.to_f
|
62
|
+
end
|
63
|
+
|
64
|
+
# convert back to latitude/longitude
|
65
|
+
lon = Math.atan2(ya, xa)
|
66
|
+
hyp = Math.sqrt(xa**2 + ya**2)
|
67
|
+
lat = Math.atan2(za, hyp)
|
68
|
+
|
69
|
+
# return answer in degrees
|
70
|
+
[to_degrees(lat), to_degrees(lon)]
|
71
|
+
end
|
72
|
+
|
73
|
+
##
|
74
|
+
# Convert degrees to radians.
|
75
|
+
#
|
76
|
+
def to_radians(degrees)
|
77
|
+
degrees * (Math::PI / 180)
|
78
|
+
end
|
79
|
+
|
80
|
+
##
|
81
|
+
# Convert radians to degrees.
|
82
|
+
#
|
83
|
+
def to_degrees(radians)
|
84
|
+
(radians * 180.0) / Math::PI
|
85
|
+
end
|
86
|
+
|
87
|
+
##
|
88
|
+
# Conversion factor: km to mi.
|
89
|
+
#
|
90
|
+
def km_in_mi
|
91
|
+
0.621371192
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
3
|
+
module Geocoder
|
4
|
+
module Lookup
|
5
|
+
extend self
|
6
|
+
|
7
|
+
##
|
8
|
+
# Query Google for the coordinates of the given address.
|
9
|
+
#
|
10
|
+
def coordinates(address)
|
11
|
+
if (results = search(address)).size > 0
|
12
|
+
place = results.first.geometry['location']
|
13
|
+
['lat', 'lng'].map{ |i| place[i] }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
##
|
18
|
+
# Query Google for the address of the given coordinates.
|
19
|
+
#
|
20
|
+
def address(latitude, longitude)
|
21
|
+
if (results = search(latitude, longitude)).size > 0
|
22
|
+
results.first.formatted_address
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
##
|
27
|
+
# Takes a search string (eg: "Mississippi Coast Coliseumf, Biloxi, MS") for
|
28
|
+
# geocoding, or coordinates (latitude, longitude) for reverse geocoding.
|
29
|
+
# Returns an array of Geocoder::Result objects,
|
30
|
+
# or nil if not found or if network error.
|
31
|
+
#
|
32
|
+
def search(*args)
|
33
|
+
return nil if args[0].blank?
|
34
|
+
doc = parsed_response(args.join(","), args.size == 2)
|
35
|
+
[].tap do |results|
|
36
|
+
if doc
|
37
|
+
doc['results'].each{ |r| results << Result.new(r) }
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
private # ---------------------------------------------------------------
|
44
|
+
|
45
|
+
##
|
46
|
+
# Returns a parsed Google geocoder search result (hash).
|
47
|
+
# Returns nil if non-200 HTTP response, timeout, or other error.
|
48
|
+
#
|
49
|
+
def parsed_response(query, reverse = false)
|
50
|
+
begin
|
51
|
+
doc = ActiveSupport::JSON.decode(fetch_data(query, reverse))
|
52
|
+
rescue SocketError
|
53
|
+
warn "Google Geocoding API connection cannot be established."
|
54
|
+
rescue TimeoutError
|
55
|
+
warn "Google Geocoding API not responding fast enough " +
|
56
|
+
"(see Geocoder::Configuration.timeout to set limit)."
|
57
|
+
end
|
58
|
+
|
59
|
+
case doc['status']; when "OK"
|
60
|
+
doc
|
61
|
+
when "OVER_QUERY_LIMIT"
|
62
|
+
warn "Google Geocoding API error: over query limit."
|
63
|
+
when "REQUEST_DENIED"
|
64
|
+
warn "Google Geocoding API error: request denied."
|
65
|
+
when "INVALID_REQUEST"
|
66
|
+
warn "Google Geocoding API error: invalid request."
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
##
|
71
|
+
# Fetches a raw Google geocoder search result (JSON string).
|
72
|
+
#
|
73
|
+
def fetch_data(query, reverse = false)
|
74
|
+
return nil if query.blank?
|
75
|
+
url = query_url(query, reverse)
|
76
|
+
timeout(Geocoder::Configuration.timeout) do
|
77
|
+
Net::HTTP.get_response(URI.parse(url)).body
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def query_url(query, reverse = false)
|
82
|
+
params = {
|
83
|
+
(reverse ? :latlng : :address) => query,
|
84
|
+
:sensor => "false"
|
85
|
+
}
|
86
|
+
"http://maps.google.com/maps/api/geocode/json?" + params.to_query
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'geocoder'
|
2
|
+
|
3
|
+
module Geocoder
|
4
|
+
if defined? Rails::Railtie
|
5
|
+
require 'rails'
|
6
|
+
class Railtie < Rails::Railtie
|
7
|
+
initializer 'geocoder.insert_into_active_record' do
|
8
|
+
ActiveSupport.on_load :active_record do
|
9
|
+
Geocoder::Railtie.insert
|
10
|
+
end
|
11
|
+
end
|
12
|
+
rake_tasks do
|
13
|
+
load "tasks/geocoder.rake"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class Railtie
|
19
|
+
def self.insert
|
20
|
+
|
21
|
+
return unless defined?(::ActiveRecord)
|
22
|
+
|
23
|
+
##
|
24
|
+
# Add methods to ActiveRecord::Base so Geocoder is accessible by models.
|
25
|
+
#
|
26
|
+
::ActiveRecord::Base.class_eval do
|
27
|
+
|
28
|
+
##
|
29
|
+
# Set attribute names and include the Geocoder module.
|
30
|
+
#
|
31
|
+
def self.geocoded_by(address_attr, options = {})
|
32
|
+
_geocoder_init(
|
33
|
+
:user_address => address_attr,
|
34
|
+
:latitude => options[:latitude] || :latitude,
|
35
|
+
:longitude => options[:longitude] || :longitude
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
##
|
40
|
+
# Set attribute names and include the Geocoder module.
|
41
|
+
#
|
42
|
+
def self.reverse_geocoded_by(latitude_attr, longitude_attr, options = {})
|
43
|
+
_geocoder_init(
|
44
|
+
:fetched_address => options[:address] || :address,
|
45
|
+
:latitude => latitude_attr,
|
46
|
+
:longitude => longitude_attr
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
def self._geocoder_init(options)
|
51
|
+
unless _geocoder_initialized?
|
52
|
+
class_inheritable_reader :geocoder_options
|
53
|
+
class_inheritable_hash_writer :geocoder_options
|
54
|
+
end
|
55
|
+
self.geocoder_options = options
|
56
|
+
unless _geocoder_initialized?
|
57
|
+
include Geocoder::ActiveRecord
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def self._geocoder_initialized?
|
62
|
+
included_modules.include? Geocoder::ActiveRecord
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|