pippa 0.1.0

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.
Files changed (45) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +20 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +706 -0
  5. data/README.md +70 -0
  6. data/Rakefile +9 -0
  7. data/lib/pippa.rb +446 -0
  8. data/lib/pippa/maps/Africa.png +0 -0
  9. data/lib/pippa/maps/Australia.png +0 -0
  10. data/lib/pippa/maps/Belgium.png +0 -0
  11. data/lib/pippa/maps/Canada.png +0 -0
  12. data/lib/pippa/maps/Caribbean.png +0 -0
  13. data/lib/pippa/maps/CentralAmerica.png +0 -0
  14. data/lib/pippa/maps/China.png +0 -0
  15. data/lib/pippa/maps/Europe.png +0 -0
  16. data/lib/pippa/maps/France.png +0 -0
  17. data/lib/pippa/maps/Germany.png +0 -0
  18. data/lib/pippa/maps/Hawaii.png +0 -0
  19. data/lib/pippa/maps/India.png +0 -0
  20. data/lib/pippa/maps/Italy.png +0 -0
  21. data/lib/pippa/maps/Japan.png +0 -0
  22. data/lib/pippa/maps/Korea.png +0 -0
  23. data/lib/pippa/maps/MalaysiaIndonesia.png +0 -0
  24. data/lib/pippa/maps/MiddleEast.png +0 -0
  25. data/lib/pippa/maps/NOSEFI.png +0 -0
  26. data/lib/pippa/maps/Netherlands.png +0 -0
  27. data/lib/pippa/maps/NewZealand.png +0 -0
  28. data/lib/pippa/maps/Philippines.png +0 -0
  29. data/lib/pippa/maps/SouthAmerica.png +0 -0
  30. data/lib/pippa/maps/UK.png +0 -0
  31. data/lib/pippa/maps/USA100.png +0 -0
  32. data/lib/pippa/maps/USA200.png +0 -0
  33. data/lib/pippa/maps/USA50-new.png +0 -0
  34. data/lib/pippa/maps/World100.png +0 -0
  35. data/lib/pippa/maps/World50-new.png +0 -0
  36. data/lib/pippa/maps/_info +34 -0
  37. data/lib/pippa/maps/_zipcodes.csv +42523 -0
  38. data/lib/pippa/version.rb +4 -0
  39. data/pippa.gemspec +27 -0
  40. data/spec/data/zipcodes.jpg +0 -0
  41. data/spec/data/zipcodes.png +0 -0
  42. data/spec/lib/map_spec.rb +43 -0
  43. data/spec/lib/pippa_spec.rb +13 -0
  44. data/spec/spec_helper.rb +31 -0
  45. metadata +162 -0
@@ -0,0 +1,70 @@
1
+ # Pippa
2
+
3
+ Pippa - a Ruby gem for producing simple map graphics overlain with
4
+ geocoded dots of given area. Dot coordinates are in screen pixels,
5
+ latitude/longitude, or US zipcode.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ gem 'pippa'
12
+
13
+ And then execute:
14
+
15
+ $ bundle install
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install pippa
20
+
21
+ ## Usage
22
+
23
+ require 'pippa'
24
+
25
+ # Get available map names.
26
+ puts Pippa.map_names
27
+
28
+ # Make a new, clean map.
29
+ map = Pippa::Map.new('USA') # or 29 other maps (default == 'World')
30
+
31
+ # Change default dark red fill to dark green.
32
+ # Changes cause dots entered so far to be rendered to graphic.
33
+ # Several other parameters also control dot appearance.
34
+ map.fill = 'DarkGreen'
35
+
36
+ # Change default to enable ImageMagick anti-aliasing by refraining
37
+ # from snapping dot coordinates to nearest pixel.
38
+ map.anti_alias = true
39
+
40
+ # Add a dot in the middle of the map using pixel coordinates.
41
+ map.add_dot(map.width/2, map.height/2, 100)
42
+
43
+ # Add a single green pixel dot at West Point, NY.
44
+ # Between calls to render, dots are drawn biggest first, so
45
+ # overlaps are generally okay.
46
+ map.add_at_lat_lon(41.5, -74.1)
47
+
48
+ # Flush buffered dots to the map.
49
+ map.render
50
+
51
+ # Add a dot with an area of 86 at a given zip code in Pennsylvania.
52
+ # This will be drawn on top of all previous dots regardless of
53
+ # size due to render above.
54
+ map.add_at_zip('18088', 86)
55
+
56
+ # Make a blob of the map e.g. suitable for Rails send_data.
57
+ # Any RMagick blob format will work in lieu of 'png'
58
+ blob = map.to_png
59
+
60
+ # Write the map directly to a file using RMagick write.
61
+ # Any RMagick writable format will work.
62
+ map.write_jpg('mymap.jpg')
63
+
64
+ ## Contributing
65
+
66
+ 1. Fork it
67
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
68
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
69
+ 4. Push to the branch (`git push origin my-new-feature`)
70
+ 5. Create new Pull Request
@@ -0,0 +1,9 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new
6
+
7
+ task :default => :spec
8
+ task :test => :spec
9
+
@@ -0,0 +1,446 @@
1
+ # Pippa - a Ruby gem for producing simple map graphics overlain with
2
+ # geocoded dots of given area. The geocoding is by lat/lon or US zipcode.
3
+ #
4
+ # Author:: Gene Ressler (mailto:gene.ressler@gmail.com)
5
+ # Copyright:: Copyright (c) 2013 Gene Ressler
6
+ # License:: See LICENSE.TXT
7
+ #
8
+ require 'pippa/version'
9
+ require 'RMagick'
10
+ require 'csv'
11
+
12
+ module Pippa
13
+
14
+ # Return a list of the valid map names.
15
+ def self.map_names
16
+ Map.info[:map].keys
17
+ end
18
+
19
+ # An image-based map class that can be overlain with dots
20
+ # of given area and location given by pixel coordinates, lat/lon,
21
+ # or zipcode (courtesy of http://federalgovernmentzipcodes.us).
22
+ class Map
23
+ include Magick
24
+
25
+ # Width of the map image in pixels
26
+ attr_reader :width
27
+
28
+ # Height of the map image in pixels
29
+ attr_reader :height
30
+
31
+ # Base size of dot edges in pixels; defaults to 1.
32
+ # Therefore a unit area is one pixel.
33
+ attr_reader :point_size
34
+
35
+ ##
36
+ # :attr_writer: point_size
37
+
38
+ # Dot fill color
39
+ attr_reader :fill
40
+
41
+ ##
42
+ # :attr_writer: fill
43
+
44
+ # Dot fill opacity
45
+ attr_reader :fill_opacity
46
+
47
+ ##
48
+ # :attr_writer: fill_opacity
49
+
50
+ # Dot border stroke color name
51
+ attr_reader :stroke
52
+
53
+ ##
54
+ # :attr_writer: stroke
55
+
56
+ # Dot border stroke width
57
+ attr_reader :stroke_width
58
+
59
+ ##
60
+ # :attr_writer: stroke_width
61
+
62
+ # RMagick image for direct manipulation, for example drawing lines and labels
63
+ attr_reader :image
64
+
65
+ # Render if we're making a change and then set a flag indicating
66
+ # whether anti-aliasing will be performed in next render.
67
+ # Default is false.
68
+ def anti_alias=(val) # :nodoc:
69
+ val = !!val
70
+ return val if val == @anti_alias
71
+ render
72
+ @anti_alias = val
73
+ end
74
+
75
+ # Return flag indicating whether anti-aliasing will be performed in next render.
76
+ def anti_alias? # :nodoc:
77
+ @anti_alias
78
+ end
79
+
80
+ # Return global map and projection information from config file.
81
+ # See +maps/_info+ for format. This is not generally very useful.
82
+ def self.info # :nodoc:
83
+ @@info ||= info_from_file
84
+ end
85
+
86
+ # Make a new map with given name.
87
+ # See the file +maps/_info+ or call Pippa#map_names for all possible.
88
+ def initialize(name = 'World')
89
+
90
+ # Set up drawing standards.
91
+ @point_size = 1
92
+ @fill = 'DarkRed'
93
+ @stroke = 'gray25'
94
+ @fill_opacity = 0.85
95
+ @stroke_width = 1
96
+ @anti_alias = false
97
+ @dots = []
98
+
99
+ # Look up global info or return if none.
100
+ return unless @map_info = Map.info[:map][name]
101
+ @image = Image.read("#{File.dirname(__FILE__)}/pippa/maps/#{@map_info[0]}").first
102
+ @width, @height = @image.columns, @image.rows
103
+
104
+ # Look up projection info, if any.
105
+ @projection_info = Map.info[:projection][name]
106
+ end
107
+
108
+ # Add a dot of given area at the given pixel coordinates.
109
+ #
110
+ # ==== Attributes
111
+ #
112
+ # * +x+ - Dot x-pixel coordinate
113
+ # * +y+ - Dot y-pixel coordinate
114
+ # * +area+ - Optional area, defaults to single pixel
115
+ #
116
+ # ==== Examples
117
+ #
118
+ # Make a map and put a dot in the middle.
119
+ #
120
+ # map = Map.new('USA')
121
+ # map.add_dot(map.width/2, map.height/2, 100)
122
+ # map.write_png('map.png')
123
+ def add_dot(x, y, area = 0)
124
+ @dots << [x, y, area]
125
+ end
126
+
127
+ # Return the pixel-xy coordinate on this map of a given latitude and longitude.
128
+ #
129
+ # ==== Attributes
130
+ #
131
+ # * +lat+ - Given latitude
132
+ # * +lon+ - Given longitude
133
+ #
134
+ # ==== Examples
135
+ #
136
+ # Get the pixel coordinate of West Point, NY.
137
+ #
138
+ # map = Map.new('USA')
139
+ # x, y = map.lat_lon_to_xy(41, -74)
140
+ def lat_lon_to_xy(lat, lon)
141
+ set_projection unless @lat_lon_to_xy
142
+ @lat_lon_to_xy.call(lat, lon)
143
+ end
144
+
145
+ # Add a dot on the map at given latitude and longitude with given area.
146
+ #
147
+ # ==== Attributes
148
+ #
149
+ # * +lat+ - Dot latitude
150
+ # * +lon+ - Dot longitude
151
+ # * +area+ - Optional area, defaults to single pixel
152
+ #
153
+ # ==== Examples
154
+ #
155
+ # Make a map and put a dot at West Point, NY.
156
+ #
157
+ # map = Map.new('USA')
158
+ # map.add_at_lat_lon(41, -74, 100)
159
+ # map.write_png('map.png')
160
+ def add_at_lat_lon(lat, lon, area = 0)
161
+ add_dot(*lat_lon_to_xy(lat, lon), area)
162
+ end
163
+
164
+ # Add a dot on the map at given 5-digit zip code.
165
+ #
166
+ # ==== Attributes
167
+ #
168
+ # * +zip+ - Zipcode
169
+ # * +area+ - Optional area, defaults to single pixel
170
+ #
171
+ # ==== Examples
172
+ #
173
+ # Make a map and put a dot at West Point, NY.
174
+ #
175
+ # map = Map.new('USA')
176
+ # map.add_at_zip('10996', 100)
177
+ # map.write_png('map.png')
178
+ def add_at_zip(zip, area = 0)
179
+ data = Map.zips[zip]
180
+ add_at_lat_lon(data[:lat], data[:long], area) if data
181
+ end
182
+
183
+ # Return a hash mapping zip codes to CSV records of zip code data.
184
+ # NB: The file is big, so this takes a while to return the first time called.
185
+ #
186
+ # +CSV::Row+ struct format (see also http://ruby-doc.org/stdlib-1.9.2/libdoc/csv/rdoc/CSV/Row.html):
187
+ #
188
+ # #<CSV::Row
189
+ # zipcode:"97475"
190
+ # zip_code_type:"PO BOX"
191
+ # city:"SPRINGFIELD"
192
+ # state:"OR"
193
+ # location_type:"PRIMARY"
194
+ # lat:44.05
195
+ # long:-123.02
196
+ # location:"NA-US-OR-SPRINGFIELD"
197
+ # decommisioned:"false"
198
+ # tax_returns_filed:nil
199
+ # estimated_population:nil
200
+ # total_wages:nil>
201
+ #
202
+ # See http://federalgovernmentzipcodes.us for more information on the zipcode data.
203
+ def self.zips
204
+ @@zips ||= zips_from_file
205
+ end
206
+
207
+ # Force rendering of all dots added so far onto the map.
208
+ # Then forget them so they're never rendered again.
209
+ def render
210
+ return if @image.nil? || @dots.empty?
211
+ @dots.sort! {|a, b| b[2] <=> a[2] } # by area, smallest last
212
+ gc = new_gc
213
+ if @anti_alias
214
+ @dots.each do |x, y, area|
215
+ side = @point_size * Math.sqrt(area)
216
+ if side <= 1
217
+ gc.point(x, y)
218
+ else
219
+ h = 0.5 * side
220
+ x1 = x - h
221
+ y1 = y - h
222
+ gc.rectangle(x1, y1, x1 + side, y1 + side)
223
+ end
224
+ end
225
+ else
226
+ @dots.each do |x, y, area|
227
+ side = @point_size * Math.sqrt(area)
228
+ x, y, side = x.round, y.round, side.round
229
+ if side <= 1
230
+ gc.point(x, y)
231
+ else
232
+ h = side / 2
233
+ x1 = x - h
234
+ y1 = y - h
235
+ gc.rectangle(x1, y1, x1 + side, y1 + side)
236
+ end
237
+ end
238
+ end
239
+ gc.draw(@image)
240
+ @dots = []
241
+ end
242
+
243
+
244
+ # Return true iff we respond to given method. Takes care of to_???
245
+ # and write_???? converters and writers of graphic formats.
246
+ def respond_to? (sym, include_private = false)
247
+ conversion_to_format(sym) || writer_to_format(sym) ? true : super
248
+ end
249
+
250
+ ##
251
+ # :method: write_xxx
252
+ # Write map as graphic file in Magick format xxx.
253
+ # File suffix is *not* added automatically.
254
+ # Get a full list of formats with this:
255
+ # Magick.formats.each {|k,v| puts k if v.include?('w') }
256
+ # :call-seq:
257
+ # write_xxx(filename)
258
+
259
+ ##
260
+ # :method: to_xxx
261
+ # Return map as a blob with Magick format +xxx+.
262
+ # Get a full list of formats with this:
263
+ # Magick.formats.each {|k,v| puts k if v.include?('*') }
264
+
265
+ # Handle special cases of missing converters, writers, and flushing attribute setters.
266
+ def method_missing(sym, *args, &block) # :nodoc:
267
+
268
+ # Handle graphic attribute setters. flushing with render first.
269
+ if GRAPHIC_ATTRIBUTE_SETTERS.include?(sym)
270
+ iv_name = "@#{sym.to_s[0..-2]}"
271
+ old_val = instance_variable_get(iv_name)
272
+ return old_val if args[0] == old_val
273
+ render
274
+ return instance_variable_set(iv_name, args[0])
275
+ end
276
+
277
+ # Handle to_??? format converters, again flushing with render.
278
+ fmt = conversion_to_format(sym)
279
+ if fmt
280
+ render
281
+ @image.format = fmt
282
+ return @image.to_blob
283
+ end
284
+
285
+ # Handle write_??? file writers, again flushing with render
286
+ fmt = writer_to_format(sym)
287
+ if fmt
288
+ render
289
+ @image.format = fmt
290
+ return @image.write(args[0])
291
+ end
292
+
293
+ # Punt on everything else.
294
+ super
295
+ end
296
+
297
+ # Make a map showing all the zip codes in the USA with
298
+ # dots of random size. Also a couple of additional dots.
299
+ def self.zipcode_map
300
+ generator = Random.new(42) # Force same on every run for testing.
301
+ m = Map.new('USA')
302
+ zips.each_key.each do |zip|
303
+ m.add_at_zip(zip, generator.rand(4) ** 2)
304
+ end
305
+ m.fill = 'red'
306
+ m.fill_opacity = 1
307
+ m.add_at_lat_lon(41, -74, 300) # West Point, NY
308
+ m.add_at_lat_lon(38, -122, 300) # Berkeley, CA
309
+ m
310
+ end
311
+
312
+ # Write the test map produced by +zipcode_map+ as png and jpg files.
313
+ def self.write_zipcode_maps
314
+ m = zipcode_map
315
+ File.open('spec/data/zipcodes.png', 'wb') { |f| f.write(m.to_png) }
316
+ m.write_jpg('spec/data/zipcodes.jpg')
317
+ end
318
+
319
+ # Run the profiler and record results.
320
+ def self.profile
321
+ require 'ruby-prof'
322
+ RubyProf.start
323
+ write_zipcode_maps
324
+ result = RubyProf.stop
325
+ File.open('profile.htm', 'w') do |f|
326
+ RubyProf::GraphHtmlPrinter.new(result).print(f)
327
+ end
328
+ end
329
+
330
+ private
331
+
332
+ #:nodoc:
333
+ GRAPHIC_ATTRIBUTE_SETTERS = [:point_size=, :fill=, :stroke=, :fill_opacity=, :stroke_width=]
334
+
335
+ # Build a new graphics context for rendering.
336
+ def new_gc
337
+ gc = Magick::Draw.new
338
+ gc.fill(@fill)
339
+ gc.stroke(@stroke)
340
+ gc.fill_opacity(@fill_opacity)
341
+ gc.stroke_width(@stroke_width)
342
+ gc
343
+ end
344
+
345
+ # Set the projection from the configuration projection information.
346
+ def set_projection
347
+ if @projection_info
348
+ case @projection_info[0]
349
+ when 'ALBER'
350
+ r = Float(@projection_info[1])
351
+ false_easting = Float(@projection_info[6])
352
+ false_northing = Float(@projection_info[7])
353
+ phi_1, phi_2, phi_0, lmd_0 = @projection_info[2..5].map {|s| Float(s) * Math::PI / 180.0 };
354
+ n = 0.5 * (Math.sin(phi_1) + Math.sin(phi_2))
355
+ c = Math.cos(phi_1) ** 2 + 2.0 * n * Math.sin(phi_1)
356
+ @lat_lon_to_xy = lambda do |lat, lon|
357
+ phi = lat * Math::PI / 180.0
358
+ lmd = lon * Math::PI / 180.0
359
+ p = r * Math.sqrt(c - 2.0 * n * Math.sin(phi)) / n
360
+ p_0 = r * Math.sqrt(c - 2.0 * n * Math.sin(phi_0)) / n
361
+ theta = n * (lmd - lmd_0)
362
+ x = false_easting + p * Math.sin(theta)
363
+ y = false_northing - (p_0 - p * Math.cos(theta))
364
+ [x, y]
365
+ end
366
+ else
367
+ fail "Unknown projection #{@projection_info[0]}"
368
+ end
369
+ else
370
+ top_lat, top_lon, bot_lat, bot_lon = @map_info[1..4].map {|s| Float(s) }
371
+ lat_scale = @height / (top_lat - bot_lat)
372
+ lon_scale = @width / (bot_lon - top_lon)
373
+ @lat_lon_to_xy = lambda do |lat, lon|
374
+ [(lon - top_lon) * lon_scale, (top_lat - lat) * lat_scale]
375
+ end
376
+ end
377
+ end
378
+
379
+ # For given string +prefix+ and a symbol like +:<prefix>_png+ or +:<prefix>_jpg+,
380
+ # return 'PNG' or 'JPG' so long as the part of the symbol after the underscore
381
+ # is a valid Magick image format with required function. Otherwise return +nil+.
382
+ def method_to_format(prefix, sym, function)
383
+ return nil unless sym.to_s =~ /^#{prefix}_(.*)$/
384
+ format_name = $1.upcase
385
+ return nil unless format = Magick.formats[format_name]
386
+ format.include?(function) && format_name
387
+ end
388
+
389
+ # Translate to_xxx to XXX if XXX is a valid Magick image format with blob function.
390
+ def conversion_to_format(sym)
391
+ method_to_format('to', sym, '*')
392
+ end
393
+
394
+ # Translate write_xxx to XXX if XXX is a valid Magick image format with write function.
395
+ def writer_to_format(sym)
396
+ method_to_format('write', sym, 'w')
397
+ end
398
+
399
+ # Format:
400
+ # MAP World World100.png 90 -170 -90 190
401
+ # PROJECTION USA50 ALBER 704.0 30.8 45.5 21.86 -99.9 232 388
402
+ def self.info_from_file
403
+ File.open("#{File.dirname(__FILE__)}/pippa/maps/_info", 'r') do |f|
404
+ data = {}
405
+ while (line = f.gets)
406
+ tag, name, *vec = line.split
407
+ tag = tag.downcase.to_sym
408
+ data[tag] ||= {}
409
+ data[tag][name] = vec
410
+ end
411
+ data
412
+ end
413
+ end
414
+
415
+ # Read CSV file of zipcode data. Much more than we need.
416
+ # TODO: Develop quicker-loading version of the data file.
417
+ # Format:
418
+ # "Zipcode","ZipCodeType","City","State","LocationType","Lat","Long",
419
+ # "Location","Decommisioned","TaxReturnsFiled","EstimatedPopulation","TotalWages"
420
+ def self.zips_from_file
421
+ CSV::HeaderConverters[:underscore_symbol] = lambda do |s|
422
+ t = s.gsub(/::/, '/')
423
+ t.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
424
+ t.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
425
+ t.tr!("-", "_")
426
+ t.downcase!
427
+ t.to_sym
428
+ end
429
+ CSV::Converters[:custom] = lambda do |s, info|
430
+ begin
431
+ [:lat, :long].include?(info.header) ? Float(s) : s
432
+ rescue
433
+ s
434
+ end
435
+ end
436
+ zips = {}
437
+ CSV.foreach("#{File.dirname(__FILE__)}/pippa/maps/_zipcodes.csv",
438
+ :headers => :first_row,
439
+ :header_converters => :underscore_symbol,
440
+ :converters => :custom) do |row|
441
+ zips[row[:zipcode]] = row if row[:lat] && row[:long]
442
+ end
443
+ zips
444
+ end
445
+ end
446
+ end