From 58ec47a00b9fb9a30286f4fd0b1992a86c3d0188 Mon Sep 17 00:00:00 2001 From: Mika Haulo Date: Wed, 25 Sep 2019 18:10:27 +0300 Subject: [PATCH 1/3] Restructure code This commit adds no new features, but prepares the codebase for upcoming changes by refactoring and restructuring it. Summary of changes: 1. Methods in Conversion are now class methods Conversion class feels more like a collections of utility methods instead of a logical entity. Therefore having them as class methods fels more appropriate approach. Also, it feels convenient not having to initialize and object (Conversion.new.lng_to_x), but just call methods directly (Conversion.lng.to_x). 2. Separate BoundingBox and Renderer from Map To keep the Map class tidy and preventing it from growing too much while adding new features, I create two new classes and moved some of the functionality from Map to them. BoundingBox is an abstaction for... well.. a bounding box. It represents a rectagular area. So far the only use case has been the map view itself, but in the future, it will have other use cases as well. Renderer takes care of transforming the values represented by Map to an image on the disk. --- lib/mapstatic.rb | 2 + lib/mapstatic/bounding_box.rb | 82 +++++++++++++++ lib/mapstatic/conversion.rb | 11 +- lib/mapstatic/map.rb | 169 +++++++------------------------ lib/mapstatic/renderer.rb | 76 ++++++++++++++ mapstatic.gemspec | 4 +- spec/models/bounding_box_spec.rb | 82 +++++++++++++++ 7 files changed, 282 insertions(+), 144 deletions(-) create mode 100644 lib/mapstatic/bounding_box.rb create mode 100644 lib/mapstatic/renderer.rb create mode 100644 spec/models/bounding_box_spec.rb diff --git a/lib/mapstatic.rb b/lib/mapstatic.rb index 2eacb8c..8604cc0 100644 --- a/lib/mapstatic.rb +++ b/lib/mapstatic.rb @@ -11,6 +11,8 @@ def self.options require 'mapstatic/errors' require 'mapstatic/version' require 'mapstatic/conversion' +require 'mapstatic/bounding_box' require 'mapstatic/map' require 'mapstatic/tile' require 'mapstatic/tile_source' +require 'mapstatic/renderer' diff --git a/lib/mapstatic/bounding_box.rb b/lib/mapstatic/bounding_box.rb new file mode 100644 index 0000000..fa0886d --- /dev/null +++ b/lib/mapstatic/bounding_box.rb @@ -0,0 +1,82 @@ +module Mapstatic + class BoundingBox + attr_accessor :left, :right, :top, :bottom + + def initialize(params={}) + @left = params.fetch(:left) + @bottom = params.fetch(:bottom) + @right = params.fetch(:right) + @top = params.fetch(:top) + end + + def to_a + [left, bottom, right, top] + end + alias_method :to_latlng_coordinates, :to_a + + def to_xy_coordinates(zoom) + [ + Conversion.lng_to_x(left, zoom), + Conversion.lat_to_y(bottom, zoom), + Conversion.lng_to_x(right, zoom), + Conversion.lat_to_y(top, zoom) + ] + end + + def center + lat = (bottom + top) / 2 + lng = (left + right) / 2 + + {lat: lat, lng: lng} + end + + def width_at(zoom) + delta = Conversion.lng_to_x(right, zoom) - Conversion.lng_to_x(left, zoom) + (delta * Map::TILE_SIZE).abs + end + + def height_at(zoom) + delta = Conversion.lat_to_y(top, zoom) - Conversion.lat_to_y(bottom, zoom) + (delta * Map::TILE_SIZE).abs + end + + def fits_in?(other) + left >= other.left and right <= other.right and top <= other.top and bottom >= other.bottom + end + + def contains?(other) + other.fits_in? self + end + + def set_to(other) + @left = other.left + @right = other.right + @top = other.top + @bottom = other.bottom + end + + def self.for(coordinates) + lngs = coordinates.map {|point| point[0]} + lats = coordinates.map {|point| point[1]} + + left = lngs.min + bottom = lats.min + right = lngs.max + top = lats.max + + BoundingBox.new top: top, bottom: bottom, left: left, right: right + end + + def self.from(center_lat:, center_lng:, width:, height:, zoom:) + x = Conversion.lng_to_x(center_lng, zoom) + y = Conversion.lat_to_y(center_lat, zoom) + + left = Conversion.x_to_lng( x - ( width / 2 ), zoom) + right = Conversion.x_to_lng( x + ( width / 2 ), zoom) + bottom = Conversion.y_to_lat( y + ( height / 2 ), zoom) + top = Conversion.y_to_lat( y - ( height / 2 ), zoom) + + BoundingBox.new top: top, bottom: bottom, left: left, right: right + end + end +end diff --git a/lib/mapstatic/conversion.rb b/lib/mapstatic/conversion.rb index 2412558..2c8d243 100644 --- a/lib/mapstatic/conversion.rb +++ b/lib/mapstatic/conversion.rb @@ -1,28 +1,25 @@ module Mapstatic - class Conversion - - def lng_to_x(lng, zoom) + def self.lng_to_x(lng, zoom) n = 2 ** zoom ((lng.to_f + 180) / 360) * n end - def x_to_lng(x, zoom) + def self.x_to_lng(x, zoom) n = 2.0 ** zoom lon_deg = x / n * 360.0 - 180.0 end - def lat_to_y(lat, zoom) + def self.lat_to_y(lat, zoom) n = 2 ** zoom lat_rad = (lat / 180) * Math::PI (1 - Math.log( Math.tan(lat_rad) + (1 / Math.cos(lat_rad)) ) / Math::PI) / 2 * n end - def y_to_lat(y, zoom) + def self.y_to_lat(y, zoom) n = 2.0 ** zoom lat_rad = Math.atan(Math.sinh(Math::PI * (1 - 2 * y / n))) lat_deg = lat_rad / (Math::PI / 180.0) end end - end diff --git a/lib/mapstatic/map.rb b/lib/mapstatic/map.rb index 9842737..232557d 100644 --- a/lib/mapstatic/map.rb +++ b/lib/mapstatic/map.rb @@ -1,162 +1,61 @@ require 'mini_magick' module Mapstatic - class Map TILE_SIZE = 256 - attr_reader :zoom, :lat, :lng, :width, :height - attr_accessor :tile_source + attr_reader :lat, :lng, :viewport, :geojson + attr_accessor :tile_source, :zoom def initialize(params={}) + @zoom = params.fetch(:zoom).to_i + if params[:bbox] - @bounding_box = params[:bbox].split(',').map(&:to_f) + left, bottom, right, top = params[:bbox] + @viewport = BoundingBox.new top: top, bottom: bottom, left: left, right: right else - @lat = params.fetch(:lat).to_f - @lng = params.fetch(:lng).to_f - @width = params.fetch(:width).to_f - @height = params.fetch(:height).to_f + @width = params.fetch(:width).to_i + @height = params.fetch(:height).to_i + lat = params.fetch(:lat, 0).to_f + lng = params.fetch(:lng, 0).to_f + + @viewport = BoundingBox.from( + center_lat: lat, + center_lng: lng, + width: @width.to_f / TILE_SIZE, + height: @height.to_f / TILE_SIZE, + zoom: @zoom + ) + end + + if params[:tile_source] + @tile_source = TileSource.new(params[:tile_source]) + else + @tile_source = TileSource.new("http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png") end - @zoom = params.fetch(:zoom).to_i - @tile_source = TileSource.new(params[:provider]) end def width - @width ||= begin - left, bottom, right, top = bounding_box_in_tiles - (right - left) * TILE_SIZE + @width || begin + delta = Conversion.lng_to_x(viewport.right, zoom) - Conversion.lng_to_x(viewport.left, zoom) + (delta * TILE_SIZE).abs end end def height - @height ||= begin - left, bottom, right, top = bounding_box_in_tiles - (bottom - top) * TILE_SIZE + @height || begin + delta = Conversion.lat_to_y(viewport.top, zoom) - Conversion.lat_to_y(viewport.bottom, zoom) + (delta * TILE_SIZE).abs end end def to_image - base_image = create_uncropped_image - base_image = fill_image_with_tiles(base_image) - crop_to_size base_image - base_image - end - - def render_map(filename) - to_image.write filename - end - - def metadata - { - :bbox => bounding_box.join(','), - :width => width.to_i, - :height => height.to_i, - :zoom => zoom, - :number_of_tiles => required_tiles.length, - } - end - - private - - def x_tile_space - Conversion.new.lng_to_x(lng, zoom) - end - - def y_tile_space - Conversion.new.lat_to_y(lat, zoom) - end - - def width_tile_space - width / TILE_SIZE - end - - def height_tile_space - height / TILE_SIZE - end - - def bounding_box - @bounding_box ||= begin - converter = Conversion.new - left = converter.x_to_lng( x_tile_space - (width_tile_space / 2), zoom) - right = converter.x_to_lng( x_tile_space + ( width_tile_space / 2 ), zoom) - top = converter.y_to_lat( y_tile_space - ( height_tile_space / 2 ), zoom) - bottom = converter.y_to_lat( y_tile_space + ( height_tile_space / 2 ), zoom) - - [ left, bottom, right, top ] - end - end - - def bounding_box_in_tiles - left, bottom, right, top = bounding_box - converter = Conversion.new - [ - converter.lng_to_x(left, zoom), - converter.lat_to_y(bottom, zoom), - converter.lng_to_x(right, zoom), - converter.lat_to_y(top, zoom) - ] + Renderer.new(self).render end - def required_x_tiles - left, bottom, right, top = bounding_box_in_tiles - Range.new(*[left, right].map(&:floor)).to_a + def to_file(filename) + Renderer.new(self).render_to(filename) end - - def required_y_tiles - left, bottom, right, top = bounding_box_in_tiles - Range.new(*[top, bottom].map(&:floor)).to_a - end - - def required_tiles - required_y_tiles.map do |y| - required_x_tiles.map{|x| Tile.new(x,y,zoom) } - end.flatten - end - - def map_tiles - @map_tiles ||= tile_source.get_tiles(required_tiles) - end - - def crop_to_size(image) - distance_from_left = (bounding_box_in_tiles[0] - required_x_tiles[0]) * TILE_SIZE - distance_from_top = (bounding_box_in_tiles[3] - required_y_tiles[0]) * TILE_SIZE - - image.crop "#{width}x#{height}+#{distance_from_left}+#{distance_from_top}" - end - - def create_uncropped_image - image = MiniMagick::Image.read(map_tiles[0]) - - uncropped_width = required_x_tiles.length * TILE_SIZE - uncropped_height = required_y_tiles.length * TILE_SIZE - - image.combine_options do |c| - c.background 'none' - c.extent [uncropped_width,uncropped_height].join('x') - end - - image - end - - def fill_image_with_tiles(image) - start = 0 - - required_y_tiles.length.times do |row| - length = required_x_tiles.length - - map_tiles.slice(start, length).each_with_index do |tile, column| - image = image.composite( MiniMagick::Image.read(tile) ) do |c| - c.geometry "+#{ (column) * TILE_SIZE }+#{ (row) * TILE_SIZE }" - end - end - - start += length - end - - image - end - + alias_method :render_map, :to_file end - - end diff --git a/lib/mapstatic/renderer.rb b/lib/mapstatic/renderer.rb new file mode 100644 index 0000000..0427bdd --- /dev/null +++ b/lib/mapstatic/renderer.rb @@ -0,0 +1,76 @@ +module Mapstatic + class Renderer + def initialize(map) + @map = map + end + + def render + fetch_tiles + create_uncropped_image + fill_image_with_tiles + crop_to_size + @image + end + + def render_to(filename) + render.write filename + end + + private + + def fetch_tiles + @tiles = @map.tile_source.get_tiles(required_tiles) + end + + def required_tiles + required_y_tiles.map do |y| + required_x_tiles.map{|x| Tile.new(x, y, @map.zoom) } + end.flatten + end + + def required_x_tiles + left, bottom, right, top = @map.viewport.to_xy_coordinates(@map.zoom) + Range.new(*[left, right].map(&:floor).sort).to_a + end + + def required_y_tiles + left, bottom, right, top = @map.viewport.to_xy_coordinates(@map.zoom) + Range.new(*[bottom, top].map(&:floor).sort).to_a + end + + def create_uncropped_image + @image = MiniMagick::Image.read(@tiles[0]) + + uncropped_width = required_x_tiles.length * Map::TILE_SIZE + uncropped_height = required_y_tiles.length * Map::TILE_SIZE + + @image.combine_options do |c| + c.background 'none' + c.extent [uncropped_width, uncropped_height].join('x') + end + end + + def fill_image_with_tiles + start = 0 + + required_y_tiles.length.times do |row| + length = required_x_tiles.length + + @tiles.slice(start, length).each_with_index do |tile, column| + @image = @image.composite( MiniMagick::Image.read(tile) ) do |c| + c.geometry "+#{ (column) * Map::TILE_SIZE }+#{ (row) * Map::TILE_SIZE }" + end + end + + start += length + end + end + + def crop_to_size + distance_from_left = (@map.viewport.to_xy_coordinates(@map.zoom)[0] - required_x_tiles[0]) * Map::TILE_SIZE + distance_from_top = (@map.viewport.to_xy_coordinates(@map.zoom)[3] - required_y_tiles[0]) * Map::TILE_SIZE + + @image.crop "#{@map.width}x#{@map.height}+#{distance_from_left}+#{distance_from_top}" + end + end +end diff --git a/mapstatic.gemspec b/mapstatic.gemspec index a807a68..ae24e44 100644 --- a/mapstatic.gemspec +++ b/mapstatic.gemspec @@ -8,10 +8,10 @@ Gem::Specification.new do |s| s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.require_paths = ["lib".freeze] s.authors = ["James Croft".freeze] - s.date = "2019-07-01" + s.date = "2019-09-25" s.email = "james@matchingnotes.com".freeze s.executables = ["mapstatic".freeze] - s.files = ["Gemfile".freeze, "Gemfile.lock".freeze, "bin/mapstatic".freeze, "lib/mapstatic".freeze, "lib/mapstatic.rb".freeze, "lib/mapstatic/cli.rb".freeze, "lib/mapstatic/conversion.rb".freeze, "lib/mapstatic/errors.rb".freeze, "lib/mapstatic/map.rb".freeze, "lib/mapstatic/tile.rb".freeze, "lib/mapstatic/tile_source.rb".freeze, "lib/mapstatic/version.rb".freeze, "spec/fixtures".freeze, "spec/fixtures/maps".freeze, "spec/fixtures/maps/london.png".freeze, "spec/fixtures/maps/thames.png".freeze, "spec/fixtures/vcr_cassettes".freeze, "spec/fixtures/vcr_cassettes/osm-london-fail.yml".freeze, "spec/fixtures/vcr_cassettes/osm-london.yml".freeze, "spec/fixtures/vcr_cassettes/osm-thames.yml".freeze, "spec/models".freeze, "spec/models/map_spec.rb".freeze, "spec/spec_helper.rb".freeze] + s.files = ["Gemfile".freeze, "Gemfile.lock".freeze, "bin/mapstatic".freeze, "lib/mapstatic.rb".freeze, "lib/mapstatic/bounding_box.rb".freeze, "lib/mapstatic/cli.rb".freeze, "lib/mapstatic/conversion.rb".freeze, "lib/mapstatic/errors.rb".freeze, "lib/mapstatic/gpx_file.rb".freeze, "lib/mapstatic/map.rb".freeze, "lib/mapstatic/painter.rb".freeze, "lib/mapstatic/painter/line_string_painter.rb".freeze, "lib/mapstatic/painter/null_painter.rb".freeze, "lib/mapstatic/renderer.rb".freeze, "lib/mapstatic/tile.rb".freeze, "lib/mapstatic/tile_source.rb".freeze, "lib/mapstatic/version.rb".freeze, "spec/fixtures/gpx/hervanta.gpx".freeze, "spec/fixtures/gpx/joensuu.gpx".freeze, "spec/fixtures/maps/london.png".freeze, "spec/fixtures/maps/thames.png".freeze, "spec/fixtures/vcr_cassettes/osm-london-fail.yml".freeze, "spec/fixtures/vcr_cassettes/osm-london.yml".freeze, "spec/fixtures/vcr_cassettes/osm-thames.yml".freeze, "spec/models/bounding_box_spec.rb".freeze, "spec/models/gpx_file_spec.rb".freeze, "spec/models/line_string_painter_spec.rb".freeze, "spec/models/map_spec.rb".freeze, "spec/models/null_painter_spec.rb".freeze, "spec/models/painter_spec.rb".freeze, "spec/spec_helper.rb".freeze] s.homepage = "https://github.com/crofty/mapstatic".freeze s.rubygems_version = "3.0.3".freeze s.summary = "Static Map Generator".freeze diff --git a/spec/models/bounding_box_spec.rb b/spec/models/bounding_box_spec.rb new file mode 100644 index 0000000..4fc0fe7 --- /dev/null +++ b/spec/models/bounding_box_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +describe Mapstatic::BoundingBox do + it "should require initial values" do + expect do + Mapstatic::BoundingBox.new + end.to raise_error KeyError + end + + it "should return its data as arrays" do + left = -0.169851 + bottom = 51.480829 + right = 0.027421 + top = 51.513658 + + bbox = Mapstatic::BoundingBox.new left: left, bottom: bottom, right: right, top: top + + expect(bbox.to_latlng_coordinates.is_a?(Array)).to be(true) + + zoom = 12 + expect(bbox.to_xy_coordinates(zoom).is_a?(Array)).to be(true) + end + + it "should calculate centerpoint correctly" do + left = -100.0 + bottom = -50.0 + right = 100.0 + top = 50.0 + + bbox = Mapstatic::BoundingBox.new left: left, bottom: bottom, right: right, top: top + center = bbox.center + + expect(center[:lat]).to eq(0) + expect(center[:lng]).to eq(0) + end + + it "should detect nested boxes correctly" do + outer = {left: -100.0, bottom: -50.0, right: 100.0, top: 50.0} + inner = {left: -90.0, bottom: -40.0, right: 90.0, top: 40.0} + intersecting = {left: -90.0, bottom: -60.0, right: 110.0, top: 50.0} + + outer_box = Mapstatic::BoundingBox.new outer + inner_box = Mapstatic::BoundingBox.new inner + intersecting_box = Mapstatic::BoundingBox.new intersecting + + expect(inner_box.fits_in? outer_box).to be(true) + expect(outer_box.contains? inner_box).to be(true) + expect(inner_box.contains? outer_box).to be(false) + expect(outer_box.fits_in? inner_box).to be(false) + expect(intersecting_box.fits_in? outer_box).to be(false) + end + + it "should build a box around given coordinates" do + coordinates = [[0.0, 0.0], [10.0, 10.0]] + bbox = Mapstatic::BoundingBox.for coordinates + + expect(bbox.left).to be <= 0 + expect(bbox.bottom).to be <= 0 + expect(bbox.right).to be >= 10 + expect(bbox.top).to be >= 10 + end + + it "should construct a box based on center coordinates and dimensions" do + outer_box = Mapstatic::BoundingBox.from( + center_lat: 0, + center_lng: 0, + width: 200, + height: 200, + zoom: 12 + ) + + inner_box = Mapstatic::BoundingBox.from( + center_lat: 0, + center_lng: 0, + width: 150, + height: 150, + zoom: 12 + ) + + expect(inner_box.fits_in? outer_box).to be(true) + end +end From e289317ca6af5e551af84060ce491802f40e67b4 Mon Sep 17 00:00:00 2001 From: Mika Haulo Date: Wed, 25 Sep 2019 18:21:15 +0300 Subject: [PATCH 2/3] Add new features to allow drawing GeoJSON features on the map At the core of this, there is a simple framework for drawing different shapes. The design of it is pretty much the same as in some of the classes in Rails/ActiveStorage. Currently only LineStrings are supported, but adding support for other geometry types is fairly easy. GpxFile is a helper class for the CLI. It reads a gpx file given as an argument, and translates it into GeoJSON format. It's only included in the CLI by default, because when using the Ruby API, it's more likely that the data is already in GeoJSON format. Also the version number is now bumped up to 0.1, because this commit creates new features. --- README.md | 74 +++-- lib/mapstatic.rb | 3 + lib/mapstatic/bounding_box.rb | 10 + lib/mapstatic/cli.rb | 35 ++- lib/mapstatic/conversion.rb | 16 + lib/mapstatic/gpx_file.rb | 61 ++++ lib/mapstatic/map.rb | 44 ++- lib/mapstatic/painter.rb | 25 ++ lib/mapstatic/painter/line_string_painter.rb | 48 +++ lib/mapstatic/painter/null_painter.rb | 12 + lib/mapstatic/renderer.rb | 34 +++ lib/mapstatic/version.rb | 2 +- mapstatic.gemspec | 4 +- spec/fixtures/gpx/hervanta.gpx | 225 ++++++++++++++ spec/fixtures/gpx/joensuu.gpx | 300 +++++++++++++++++++ spec/models/gpx_file_spec.rb | 36 +++ spec/models/line_string_painter_spec.rb | 38 +++ spec/models/map_spec.rb | 133 ++++++-- spec/models/null_painter_spec.rb | 32 ++ spec/models/painter_spec.rb | 7 + spec/spec_helper.rb | 31 ++ 21 files changed, 1111 insertions(+), 59 deletions(-) create mode 100644 lib/mapstatic/gpx_file.rb create mode 100644 lib/mapstatic/painter.rb create mode 100644 lib/mapstatic/painter/line_string_painter.rb create mode 100644 lib/mapstatic/painter/null_painter.rb create mode 100644 spec/fixtures/gpx/hervanta.gpx create mode 100644 spec/fixtures/gpx/joensuu.gpx create mode 100644 spec/models/gpx_file_spec.rb create mode 100644 spec/models/line_string_painter_spec.rb create mode 100644 spec/models/null_painter_spec.rb create mode 100644 spec/models/painter_spec.rb diff --git a/README.md b/README.md index 158055d..64ae558 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A CLI and Ruby Gem for generating static maps from map tile servers. ## Installation gem install mapstatic - + if you want to use command line for creating maps then additional gems are required gem install mapstatic @@ -22,31 +22,42 @@ if you want to use command line for creating maps then additional gems are requi There are two ways to generate a static map from the mapstatic CLI. 1. Specifying a bounding box and zoom level + + The command below generates a map of the UK using the [OpenStreetMap](http://www.openstreetmap.org/) tileset. The width and height of the resulting image (`uk.png`) are determined by the bounding box and the zoom level. If you don't know the bounding box of the area then [this is a useful tool](http://boundingbox.klokantech.com/). + + ```.bash + mapstatic map uk.png \ + --zoom=5 \ + -- bbox=-11.29,49.78,2.45,58.78 + ``` + + ![UK](http://matchingnotes.com/images/uk.png) + 2. Specifying a center lat, center lng, width, height and zoom level -The command below generates a map of the UK using the [OpenStreetMap](http://www.openstreetmap.org/) tileset. The width and height of the resulting image (`uk.png`) are determined by the bounding box and the zoom level. If you don't know the bounding box of the area then [this is a useful tool](http://boundingbox.klokantech.com/). + Alternatively, you can specify a central latitude and longitude and specify the width and height. -```.bash -mapstatic map uk.png \ - --zoom=5 \ - --bbox=-11.29,49.78,2.45,58.78 -``` + ```.bash + mapstatic map silicon-roundabout.png \ + --zoom=18 \ + --lat=51.52567 \ + --lng=-0.08750 \ + --width=600 \ + --height=300 + ``` -![UK](http://matchingnotes.com/images/uk.png) +![Silicon Roundabout](http://matchingnotes.com/images/silicon-roundabout.png) -Alternatively, you can specify a central latitude and longitude and specify the width and height. +Optionally a gpx file can be specified. In that case, the routes and tracks contained in that file will be drawn on top of the map. The map view will be automatically adjusted to fit the given gpx route data. ```.bash -mapstatic map silicon-roundabout.png \ - --zoom=18 \ - --lat=51.52567 \ - --lng=-0.08750 \ +mapstatic map map.png \ + --zoom=12 \ --width=600 \ - --height=300 + --height=300 \ + --gpx=file.gpx ``` -![Silicon Roundabout](http://matchingnotes.com/images/silicon-roundabout.png) - ## Changing the provider Mapstatic can generate maps from any slippy map tile server. The tile provider can be specified with a URL template @@ -88,15 +99,38 @@ Mapstatic can be used in your application code to generate maps and get metadata ```.ruby require 'mapstatic' + +# Initialize map = Mapstatic::Map.new( - :zoom => 12, - :bbox => "-0.218894,51.450943,0.014382,51.553755", - :provider => 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + width: 400, + height: 200 + zoom: 11, + provider: 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' ) -map.render_map 'london.png' + +# optional: set geojson data layer +geojson_data = { + type: "FeatureCollection", + features: ... # Currently only LineString is supported by Mapstatic +} +map.geojson = geojson_data +map.fit_bounds # Call this to set map dimensions so that geojson data fits into map area + +# Render to file +map.to_file 'london.png' map.metadata # Returns the map metadata + +# You can also just render the image without writing it to a file. +# This will produce a MiniMagic image object. +image = map.to_image ``` +### Supported GeoJSON feature types + +* LineString + +To add support for more types, inherit a new class from Mapstatic::Painter, implement required methods, and add the class to painter_class_for method in `renderer.rb`. + ## License Mapstatic is licensed under the MIT license. diff --git a/lib/mapstatic.rb b/lib/mapstatic.rb index 8604cc0..4e14f72 100644 --- a/lib/mapstatic.rb +++ b/lib/mapstatic.rb @@ -16,3 +16,6 @@ def self.options require 'mapstatic/tile' require 'mapstatic/tile_source' require 'mapstatic/renderer' +require 'mapstatic/painter' +require 'mapstatic/painter/null_painter' +require 'mapstatic/painter/line_string_painter' diff --git a/lib/mapstatic/bounding_box.rb b/lib/mapstatic/bounding_box.rb index fa0886d..2cac0ee 100644 --- a/lib/mapstatic/bounding_box.rb +++ b/lib/mapstatic/bounding_box.rb @@ -30,6 +30,16 @@ def center {lat: lat, lng: lng} end + def center=(lat:, lng:) + delta_lat = lat - center[:lat] + delta_lng = lng - center[:lng] + + @left += delta_lng + @bottom += delta_lat + @right += delta_lng + @top += delta_lat + end + def width_at(zoom) delta = Conversion.lng_to_x(right, zoom) - Conversion.lng_to_x(left, zoom) (delta * Map::TILE_SIZE).abs diff --git a/lib/mapstatic/cli.rb b/lib/mapstatic/cli.rb index c486546..472f195 100644 --- a/lib/mapstatic/cli.rb +++ b/lib/mapstatic/cli.rb @@ -1,6 +1,8 @@ require 'mapstatic' +require 'mapstatic/gpx_file' require 'awesome_print' require 'thor' +require 'json' class Mapstatic::CLI < Thor @@ -29,24 +31,53 @@ class Mapstatic::CLI < Thor OpenStreetMap contributors). You can generate a map using any tile set by passing the --provider option. + + To draw a gpx track on top of the map, you can pass a file with --gpx: + + $ mapstatic map uk.png --zoom=6 --width=320 --height=290 --gpx=file.gpx LONGDESC - option :zoom, :required => true + option :zoom option :provider, :default => 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' option :bbox option :lat option :lng option :width, :default => 256 option :height, :default => 256 + option :gpx option :dryrun, :type => :boolean, :default => false def map(filename) params = Hash[options.map{|(k,v)| [k.to_sym,v]}] + if params[:bbox] + bbox = params[:bbox].split(",").map { |c| c.to_f } + params[:bbox] = bbox + end + map = Mapstatic::Map.new(params) + if options[:gpx] + gpx_file = Mapstatic::GpxFile.new options[:gpx] + geojson_data = gpx_file.geojson_data + + # Drawing only one geojson feature is supported at the moment. So just pick + # the first one on the file. + first_track = geojson_data[:features].first + map.geojson = first_track + map.fit_bounds + end + map.render_map(filename) unless options[:dryrun] - ap map.metadata + + metadata = { + map_bbox: map.viewport.to_a.join(','), + width: map.width.to_i, + height: map.height.to_i, + zoom: map.zoom + } + + ap metadata end end diff --git a/lib/mapstatic/conversion.rb b/lib/mapstatic/conversion.rb index 2c8d243..5e84496 100644 --- a/lib/mapstatic/conversion.rb +++ b/lib/mapstatic/conversion.rb @@ -21,5 +21,21 @@ def self.y_to_lat(y, zoom) lat_rad = Math.atan(Math.sinh(Math::PI * (1 - 2 * y / n))) lat_deg = lat_rad / (Math::PI / 180.0) end + + # Convert pixel coordinate x from Earth perspective (i.e the reference point, or the 0 value + # is at the prime meridian) to image perspective. The zero point of the image depends on its + # bounding box. + def self.x_to_px(x, bbox_center_x, bbox_width, tile_size) + px = (x - bbox_center_x) * tile_size + bbox_width / 2 + px.round + end + + # Convert pixel coordinate y from Earth perspective (i.e the reference point, or the 0 value + # is at the equator) to image perspective. The zero point of the image depends on its + # bounding box. + def self.y_to_px(y, bbox_center_y, bbox_height, tile_size) + px = (y - bbox_center_y) * tile_size + bbox_height / 2 + px.round + end end end diff --git a/lib/mapstatic/gpx_file.rb b/lib/mapstatic/gpx_file.rb new file mode 100644 index 0000000..601e85c --- /dev/null +++ b/lib/mapstatic/gpx_file.rb @@ -0,0 +1,61 @@ +require 'nokogiri' + +module Mapstatic + class GpxFile + attr_reader :tracks, :routes + + def initialize(filename) + @filename = filename + @tracks = [] + @routes = [] + parse + end + + def geojson_data + features = features_from(@tracks) + features_from(@routes) + + { + type: "FeatureCollection", + features: features + } + end + + def to_geojson + geojson_data.to_json + end + + def self.to_geojson(filename) + GpxFile.new(filename).to_geojson + end + + private + + def parse + xml = Nokogiri::XML File.open(@filename) + + xml.css("trk").each do |trk| + @tracks << trk.css("trkpt").map do |pt| + [pt.attributes["lon"].value.to_f, pt.attributes["lat"].value.to_f] + end + end + + xml.css("rte").each do |rte| + @routes << rte.css("rtept").map do |pt| + [pt.attributes["lon"].value.to_f, pt.attributes["lat"].value.to_f] + end + end + end + + def features_from(tracks) + tracks.map do |track| + { + type: "Feature", + geometry: { + type: "LineString", + coordinates: track + } + } + end + end + end +end diff --git a/lib/mapstatic/map.rb b/lib/mapstatic/map.rb index 232557d..6a3f766 100644 --- a/lib/mapstatic/map.rb +++ b/lib/mapstatic/map.rb @@ -1,14 +1,17 @@ require 'mini_magick' +require 'json' module Mapstatic class Map TILE_SIZE = 256 + MAX_ZOOM = 19 + MIN_ZOOM = 0 - attr_reader :lat, :lng, :viewport, :geojson - attr_accessor :tile_source, :zoom + attr_reader :lat, :lng, :viewport, :geojson, :zoom + attr_accessor :tile_source def initialize(params={}) - @zoom = params.fetch(:zoom).to_i + @zoom = params.fetch(:zoom, 0).to_i if params[:bbox] left, bottom, right, top = params[:bbox] @@ -49,6 +52,41 @@ def height end end + def zoom=(new_zoom) + @zoom = new_zoom + center = @viewport.center + @viewport = BoundingBox.from( + center_lat: center[:lat], + center_lng: center[:lng], + width: width.to_f / TILE_SIZE, + height: height.to_f / TILE_SIZE, + zoom: @zoom + ) + end + + def geojson=(data) + if data.is_a? String + @geojson = JSON.parse data + elsif data.is_a? Hash + # This looks really ugly, but it's just a quick and dirty way to ensure that keys in + # the hash are strings, not symbols. + @geojson = JSON.parse data.to_json + end + end + + def fit_bounds + return if @geojson.nil? + + coordinates = @geojson["geometry"]["coordinates"] + geojson_bbox = BoundingBox.for(coordinates) + @viewport.center = {lat: geojson_bbox.center[:lat], lng: geojson_bbox.center[:lng]} + + MAX_ZOOM.downto(MIN_ZOOM) do |zoom| + self.zoom = zoom + break if geojson_bbox.fits_in? @viewport + end + end + def to_image Renderer.new(self).render end diff --git a/lib/mapstatic/painter.rb b/lib/mapstatic/painter.rb new file mode 100644 index 0000000..894f473 --- /dev/null +++ b/lib/mapstatic/painter.rb @@ -0,0 +1,25 @@ +module Mapstatic + class Painter + attr_reader :feature + attr_reader :map + attr_accessor :stroke_width + attr_accessor :stroke_color + + # Implement this method in a subclass. + def self.accept?(geometry_type) + false + end + + def initialize(params={}) + @map = params.fetch(:map) + @feature = params.fetch(:feature) + @stroke_color = params.fetch(:stroke_color, "rgb(51, 136, 255)") + @stroke_width = params.fetch(:stroke_width, 4) + end + + # Implement this method in a subclass, have it return an ImageMagick Image. + def paint_to(image, viewport) + raise NotImplementedError + end + end +end diff --git a/lib/mapstatic/painter/line_string_painter.rb b/lib/mapstatic/painter/line_string_painter.rb new file mode 100644 index 0000000..0564a44 --- /dev/null +++ b/lib/mapstatic/painter/line_string_painter.rb @@ -0,0 +1,48 @@ +module Mapstatic + class Painter::LineStringPainter < Painter + def self.accept?(geometry_type) + geometry_type == "LineString" + end + + def paint_to(image, viewport) + # Convert coordinates to the corresponding pixel locations on + # image canvas. + # This is a two step process: + # 1. Convert latlng-coordinates to pixel-coordinates. + # 2. Convert pixel coordinates from Earth perspective to image perspective. + # All conversions require the zoom level we're working on, but the second step + # also requires a new reference, which is the image bounding box (calculated above). + # Also be careful when working on latlng-coordinates in array form - make sure + # which order they are in. + + coordinates = feature["geometry"]["coordinates"] + + xy_points = coordinates.map do |coordinate| + px = Conversion.x_to_px( + Conversion.lng_to_x(coordinate[0], map.zoom), + Conversion.lng_to_x(viewport.center[:lng], map.zoom), + viewport.width_at(map.zoom), + Map::TILE_SIZE + ) + + py = Conversion.y_to_px( + Conversion.lat_to_y(coordinate[1], map.zoom), + Conversion.lat_to_y(viewport.center[:lat], map.zoom), + viewport.height_at(map.zoom), + Map::TILE_SIZE + ) + + "#{px},#{py}" + end + + image.combine_options do |c| + c.fill "none" + c.stroke stroke_color + c.strokewidth stroke_width + c.draw "polyline #{xy_points.join(" ").strip}" + end + + image + end + end +end diff --git a/lib/mapstatic/painter/null_painter.rb b/lib/mapstatic/painter/null_painter.rb new file mode 100644 index 0000000..4f65b60 --- /dev/null +++ b/lib/mapstatic/painter/null_painter.rb @@ -0,0 +1,12 @@ +module Mapstatic + class Painter::NullPainter < Painter + def self.accept?(geometry_type) + true + end + + def paint_to(image, viewport) + # Do nothing, just return the original image. + image + end + end +end diff --git a/lib/mapstatic/renderer.rb b/lib/mapstatic/renderer.rb index 0427bdd..3063a4a 100644 --- a/lib/mapstatic/renderer.rb +++ b/lib/mapstatic/renderer.rb @@ -8,6 +8,7 @@ def render fetch_tiles create_uncropped_image fill_image_with_tiles + draw_geometry if @map.geojson crop_to_size @image end @@ -66,11 +67,44 @@ def fill_image_with_tiles end end + def draw_geometry + if @map.geojson["type"] == "Feature" + features = [@map.geojson] + elsif @map.geojson["type"] == "FeatureCollection" + features = @map.geojson["features"] + end + + left = Conversion.x_to_lng(required_x_tiles.first, @map.zoom) + top = Conversion.y_to_lat(required_y_tiles.first, @map.zoom) + + # The +1s here are for getting the bottom right location for each tile - the tile + # number itself points to the top left corner. + right = Conversion.x_to_lng(required_x_tiles.last+1, @map.zoom) + bottom = Conversion.y_to_lat(required_y_tiles.last+1, @map.zoom) + + uncropped_viewport = BoundingBox.new left: left, bottom: bottom, right: right, top: top + + features&.each do |feature| + painter_for(feature).paint_to(@image, uncropped_viewport) + end + end + def crop_to_size distance_from_left = (@map.viewport.to_xy_coordinates(@map.zoom)[0] - required_x_tiles[0]) * Map::TILE_SIZE distance_from_top = (@map.viewport.to_xy_coordinates(@map.zoom)[3] - required_y_tiles[0]) * Map::TILE_SIZE @image.crop "#{@map.width}x#{@map.height}+#{distance_from_left}+#{distance_from_top}" end + + def painter_for(feature) + painter_class_for(feature["geometry"]["type"]).new(map: @map, feature: feature) + end + + def painter_class_for(feature_type) + # To add more painters, inherit a new class from Mapstatic::Painter, implement + # required methods, and add the class to this array. + painters = [Painter::LineStringPainter] + painter_class = painters.detect {|klass| klass.accept? feature_type} || Painter::NullPainter + end end end diff --git a/lib/mapstatic/version.rb b/lib/mapstatic/version.rb index c2a49b6..0e87a4d 100644 --- a/lib/mapstatic/version.rb +++ b/lib/mapstatic/version.rb @@ -1,3 +1,3 @@ module Mapstatic - VERSION = '0.0.2' + VERSION = '0.1' end diff --git a/mapstatic.gemspec b/mapstatic.gemspec index ae24e44..d9c23d5 100644 --- a/mapstatic.gemspec +++ b/mapstatic.gemspec @@ -1,9 +1,9 @@ # -*- encoding: utf-8 -*- -# stub: mapstatic 0.0.2 ruby lib +# stub: mapstatic 0.1 ruby lib Gem::Specification.new do |s| s.name = "mapstatic".freeze - s.version = "0.0.2" + s.version = "0.1" s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.require_paths = ["lib".freeze] diff --git a/spec/fixtures/gpx/hervanta.gpx b/spec/fixtures/gpx/hervanta.gpx new file mode 100644 index 0000000..2bc61b7 --- /dev/null +++ b/spec/fixtures/gpx/hervanta.gpx @@ -0,0 +1,225 @@ + + + + Hervanta + + + + Hervanta + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/fixtures/gpx/joensuu.gpx b/spec/fixtures/gpx/joensuu.gpx new file mode 100644 index 0000000..8e1bc14 --- /dev/null +++ b/spec/fixtures/gpx/joensuu.gpx @@ -0,0 +1,300 @@ + + + + Joensuu + + + + Joensuu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/models/gpx_file_spec.rb b/spec/models/gpx_file_spec.rb new file mode 100644 index 0000000..901cc56 --- /dev/null +++ b/spec/models/gpx_file_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe Mapstatic::GpxFile do + it "should require filename" do + expect do + Mapstatic::GpxFile.new + end.to raise_error ArgumentError + end + + it "should parse gpx file correctly" do + gpx = Mapstatic::GpxFile.new "spec/fixtures/gpx/joensuu.gpx" + expect(gpx.tracks.length).to eq 1 + expect(gpx.routes.length).to eq 0 + expect(gpx.tracks.first.length).to eq 288 + end + + it "should output proper geojson data" do + gpx = Mapstatic::GpxFile.new "spec/fixtures/gpx/joensuu.gpx" + output = gpx.geojson_data + + expect(output.is_a? Hash).to be true + expect(output[:type]).to eq "FeatureCollection" + expect(output[:features].is_a? Array).to be true + expect(output[:features].count).to eq 1 + + feature = output[:features].first + + expect(feature[:type]).to eq "Feature" + expect(feature[:geometry][:type]).to eq "LineString" + expect(feature[:geometry][:coordinates].is_a? Array).to be true + + expect do + JSON.parse gpx.to_geojson + end.not_to raise_error + end +end diff --git a/spec/models/line_string_painter_spec.rb b/spec/models/line_string_painter_spec.rb new file mode 100644 index 0000000..2aa0212 --- /dev/null +++ b/spec/models/line_string_painter_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe Mapstatic::Painter::LineStringPainter do + it "should only accept LineString geometry type" do + expect(Mapstatic::Painter::LineStringPainter.accept? "LineString").to be(true) + expect(Mapstatic::Painter::LineStringPainter.accept? "Point").to be(false) + expect(Mapstatic::Painter::LineStringPainter.accept? "Polygon").to be(false) + expect(Mapstatic::Painter::LineStringPainter.accept? "MultiPoint").to be(false) + expect(Mapstatic::Painter::LineStringPainter.accept? "MultiLineString").to be(false) + expect(Mapstatic::Painter::LineStringPainter.accept? "MultiPolygon").to be(false) + end + + it "should draw without errors" do + test_file = tempfile + FileUtils.cp "spec/fixtures/maps/london.png", test_file + image = MiniMagick::Image.new test_file + image.resize "256x256" + + map = Mapstatic::Map.new( + lat: 51.515579783755925, + lng: -0.1373291015625, + zoom: 11, + width: 256, + height: 256, + ) + feature = line_string.to_json + map.geojson = feature + map.fit_bounds + + painter = Mapstatic::Painter::LineStringPainter.new(map: map, feature: JSON.parse(feature)) + painter.paint_to image, map.viewport + + expect(image.type).to eq("PNG") + + File.delete test_file + end + +end diff --git a/spec/models/map_spec.rb b/spec/models/map_spec.rb index 3b4614b..c89259b 100644 --- a/spec/models/map_spec.rb +++ b/spec/models/map_spec.rb @@ -1,17 +1,85 @@ require 'spec_helper' describe Mapstatic::Map do + it "returns correct width and height" do + map = Mapstatic::Map.new( + lat: 51.515579783755925, + lng: -0.1373291015625, + zoom: 12, + width: 256, + height: 256, + ) + + expect(map.width).to eql(256) + expect(map.height).to eql(256) + end + + it "doesn't crash when trying to fit bounds with no geojson data provided" do + map = Mapstatic::Map.new( + lat: 51.515579783755925, + lng: -0.1373291015625, + zoom: 12, + width: 256, + height: 256, + ) + + expect do + map.fit_bounds + end.not_to raise_error + end + + it "should store width and height as integers" do + map = Mapstatic::Map.new( + lat: 51.515579783755925, + lng: -0.1373291015625, + zoom: 12, + width: "256", # Notice that width and height are given as strings + height: "256", + ) + + expect(map.width.is_a? Integer).to be true + expect(map.height.is_a? Integer).to be true + end + + it "should be able to take geojson data as a Hash" do + map = Mapstatic::Map.new( + lat: 51.515579783755925, + lng: -0.1373291015625, + zoom: 12, + width: 256, + height: 256, + ) + + expect do + map.geojson = line_string + map.fit_bounds + end.not_to raise_error + end + + it "should be able to take geojson data as a GeoJSON string" do + map = Mapstatic::Map.new( + lat: 51.515579783755925, + lng: -0.1373291015625, + zoom: 12, + width: 256, + height: 256, + ) + + expect do + map.geojson = line_string.to_json + map.fit_bounds + end.not_to raise_error + end describe "the resulting image" do it "is the correct image when got via lat lng" do output_path = 'london.png' map = Mapstatic::Map.new( - :lat => 51.515579783755925, - :lng => -0.1373291015625, - :zoom => 11, - :width => 256, - :height => 256, - :provider => 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + lat: 51.515579783755925, + lng: -0.1373291015625, + zoom: 11, + width: 256, + height: 256, ) VCR.use_cassette('osm-london') do map.render_map output_path @@ -23,9 +91,8 @@ it "is the correct image when got via bounding box" do output_path = 'london.png' map = Mapstatic::Map.new( - :bbox => "-0.2252197265625,51.4608524464555,-0.0494384765625,51.570241445811234", - :zoom => 11, - :provider => 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + bbox: [-0.2252197265625,51.4608524464555,-0.0494384765625,51.570241445811234], + zoom: 11, ) VCR.use_cassette('osm-london') do map.render_map output_path @@ -34,13 +101,11 @@ File.delete output_path end - it "renders the correct image" do output_path = 'thames.png' map = Mapstatic::Map.new( - :provider => 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - :zoom => 12, - :bbox => '-0.169851,51.480829,0.027421,51.513658' + bbox: [-0.169851,51.480829,0.027421,51.513658], + zoom: 12, ) VCR.use_cassette('osm-thames') do map.render_map output_path @@ -48,10 +113,6 @@ images_are_identical(output_path, 'spec/fixtures/maps/thames.png') File.delete output_path end - - def images_are_identical(image1, image2) - `compare -metric MAE #{image1} #{image2} null: 2>&1`.chomp.should == "0 (0)" - end end describe '#render_map' do @@ -59,9 +120,8 @@ def images_are_identical(image1, image2) it 'raises TileRequestError' do output_path = 'london.png' map = Mapstatic::Map.new( - :bbox => '-0.2252197265625,51.4608524464555,-0.0494384765625,51.570241445811234', - :zoom => 11, - :provider => 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' + bbox: [-0.2252197265625,51.4608524464555,-0.0494384765625,51.570241445811234], + zoom: 11, ) expect do @@ -79,13 +139,19 @@ def images_are_identical(image1, image2) context "when calculated from the bounding box" do it "doubles with each zoom level" do - bbox = '-11.29,49.78,2.45,59.71' + bbox = [-11.29,49.78,2.45,59.71] - image = Mapstatic::Map.new( :zoom => 6, :bbox => bbox) - expect( image.width.to_i ).to eql( 625 ) + map1 = Mapstatic::Map.new( + bbox: bbox, + zoom: 6, + ) + expect( map1.width.to_i ).to eql( 625 ) - image = Mapstatic::Map.new( :zoom => 7, :bbox => bbox) - expect( image.width.to_i ).to eql( 1250 ) + map2 = Mapstatic::Map.new( + bbox: bbox, + zoom: map1.zoom + 1, + ) + expect( map2.width.to_i ).to eql( map1.width.to_i * 2 ) end end @@ -96,13 +162,18 @@ def images_are_identical(image1, image2) context "when calculated from the bounding box" do it "doubles with each zoom level" do - bbox = '-11.29,49.78,2.45,59.71' - - image = Mapstatic::Map.new( :zoom => 2, :bbox => bbox) - expect( image.height.to_i ).to eql( 49 ) + bbox = [-11.29, 49.78, 2.45, 59.71] + map1 = Mapstatic::Map.new( + bbox: bbox, + zoom: 2, + ) + expect( map1.height.to_i ).to eql( 49 ) - image = Mapstatic::Map.new( :zoom => 3, :bbox => bbox) - expect( image.height.to_i ).to eql( 98 ) + map2 = Mapstatic::Map.new( + bbox: bbox, + zoom: 3, + ) + expect( map2.height.to_i ).to eql( map1.height.to_i * 2 ) end end diff --git a/spec/models/null_painter_spec.rb b/spec/models/null_painter_spec.rb new file mode 100644 index 0000000..3d8f672 --- /dev/null +++ b/spec/models/null_painter_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Mapstatic::Painter::NullPainter do + it "should accept any geometry type, even garbage" do + expect(Mapstatic::Painter::NullPainter.accept? "LineString").to be(true) + expect(Mapstatic::Painter::NullPainter.accept? "foo").to be(true) + end + + it "should draw without errors" do + test_file = tempfile + FileUtils.cp "spec/fixtures/maps/london.png", test_file + image = MiniMagick::Image.new test_file + image.resize "256x256" + + map = Mapstatic::Map.new( + lat: 51.515579783755925, + lng: -0.1373291015625, + zoom: 11, + width: 256, + height: 256, + ) + feature = line_string + map.geojson = feature + map.fit_bounds + + painter = Mapstatic::Painter::NullPainter.new(map: map, feature: feature) + painter.paint_to image, map.viewport + + expect(image.type).to eq("PNG") + end + +end diff --git a/spec/models/painter_spec.rb b/spec/models/painter_spec.rb new file mode 100644 index 0000000..8344f0f --- /dev/null +++ b/spec/models/painter_spec.rb @@ -0,0 +1,7 @@ +require 'spec_helper' + +describe Mapstatic::Painter do + it "should not accept any geometry type" do + expect(Mapstatic::Painter.accept? "LineString").to be(false) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b13b2fb..7ad88de 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,38 @@ require 'mapstatic' +require 'mapstatic/gpx_file' require 'vcr' +require 'json' VCR.configure do |c| c.cassette_library_dir = 'spec/fixtures/vcr_cassettes' c.hook_into :webmock end + +def images_are_identical(image1, image2) + expect(`compare -metric MAE #{image1} #{image2} null: 2>&1`.chomp).to eq("0 (0)") +end + +def line_string + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [[-0.3481, 51.5283], [0,2208, 51,4462]] + } + } +end + +def geojson_data + { + type: "FeatureCollection", + features: [line_string] + } +end + +def tempfile + file = Tempfile.new ["mapstatic", ".png"] + filename = file.path + file.close + file.unlink + filename +end From 57024efb46d01e2c3aa661748046bae5704adab0 Mon Sep 17 00:00:00 2001 From: Mika Haulo Date: Wed, 25 Sep 2019 18:27:40 +0300 Subject: [PATCH 3/3] Clean up some files Mostly cosmetic changes, such as removing unnecessary comments and obsolete things from Rakefile and gemspec file. This commit also bring Gemfile.lock and contributor list up to date. --- Gemfile.lock | 6 +++--- Rakefile | 31 ++++++++----------------------- mapstatic.gemspec | 28 ++++++++++++++++------------ 3 files changed, 27 insertions(+), 38 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6570010..560aacf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - mapstatic (0.0.2) + mapstatic (0.1) mini_magick (~> 4.9) typhoeus (~> 1.3) @@ -18,7 +18,7 @@ GEM ffi (>= 1.3.0) ffi (1.11.1) hashdiff (0.4.0) - mini_magick (4.9.3) + mini_magick (4.9.5) public_suffix (3.1.1) rake (12.3.2) rspec (3.8.0) @@ -50,7 +50,7 @@ PLATFORMS DEPENDENCIES awesome_print (< 2.0) mapstatic! - rake (~> 12) + rake rspec (~> 3) thor (>= 0.19.0, < 2.0) vcr (~> 3) diff --git a/Rakefile b/Rakefile index 80cc96d..c929717 100644 --- a/Rakefile +++ b/Rakefile @@ -10,48 +10,33 @@ RSpec::Core::RakeTask.new do |t| t.rspec_opts = %w(--format documentation --colour) end - task :default => ["spec"] -# This builds the actual gem. For details of what all these options -# mean, and other ones you can add, check the documentation here: -# -# http://rubygems.org/read/chapter/20 -# spec = Gem::Specification.new do |s| - - # Change these as appropriate s.name = "mapstatic" s.version = Mapstatic::VERSION s.summary = "Static Map Generator" - s.author = "James Croft" + s.authors = ["James Croft", "Mika Haulo", "Tim Neems", "Olli Huotari", "Michael O'Toole"] s.email = "james@matchingnotes.com" s.homepage = "https://github.com/crofty/mapstatic" + s.license = "MIT" - s.has_rdoc = true - # You should probably have a README of some kind. Change the filename - # as appropriate - # s.extra_rdoc_files = %w(README) - # s.rdoc_options = %w(--main README) - - # Add any extra files to include in the gem (like your README) s.files = %w(Gemfile Gemfile.lock) + Dir.glob("{spec,lib}/**/*") s.require_paths = ["lib"] s.executables << 'mapstatic' - # If you want to depend on other gems, add them here, along with any - # relevant versions - # s.add_dependency("some_other_gem", "~> 0.1.0") s.add_dependency('mini_magick', '~> 4.9') s.add_dependency('typhoeus', '~> 1.3') + s.add_dependency('nokogiri', '~> 1.10') - s.add_development_dependency('thor', ['>= 0.19.0', '< 2.0']) # install these if you want to use command line env - s.add_development_dependency('awesome_print', '< 2.0') # install these if you want to use command line env - - s.add_development_dependency('rake') # needed so that 'bundle exec rake' will work as expected + s.add_development_dependency('rake', '~> 12') # needed so that 'bundle exec rake' will work as expected s.add_development_dependency('rspec', '~> 3') s.add_development_dependency('vcr', '~> 3') s.add_development_dependency('webmock', '~> 2') + + # install these if you want to use command line env + s.add_development_dependency('thor', ['>= 0.19.0', '< 2.0']) + s.add_development_dependency('awesome_print', '< 2.0') end # This task actually builds the gem. We also regenerate a static diff --git a/mapstatic.gemspec b/mapstatic.gemspec index d9c23d5..fd269ad 100644 --- a/mapstatic.gemspec +++ b/mapstatic.gemspec @@ -7,12 +7,13 @@ Gem::Specification.new do |s| s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.require_paths = ["lib".freeze] - s.authors = ["James Croft".freeze] - s.date = "2019-09-25" + s.authors = ["James Croft".freeze, "Mika Haulo".freeze, "Tim Neems".freeze, "Olli Huotari".freeze, "Michael O'Toole".freeze] + s.date = "2019-09-28" s.email = "james@matchingnotes.com".freeze s.executables = ["mapstatic".freeze] - s.files = ["Gemfile".freeze, "Gemfile.lock".freeze, "bin/mapstatic".freeze, "lib/mapstatic.rb".freeze, "lib/mapstatic/bounding_box.rb".freeze, "lib/mapstatic/cli.rb".freeze, "lib/mapstatic/conversion.rb".freeze, "lib/mapstatic/errors.rb".freeze, "lib/mapstatic/gpx_file.rb".freeze, "lib/mapstatic/map.rb".freeze, "lib/mapstatic/painter.rb".freeze, "lib/mapstatic/painter/line_string_painter.rb".freeze, "lib/mapstatic/painter/null_painter.rb".freeze, "lib/mapstatic/renderer.rb".freeze, "lib/mapstatic/tile.rb".freeze, "lib/mapstatic/tile_source.rb".freeze, "lib/mapstatic/version.rb".freeze, "spec/fixtures/gpx/hervanta.gpx".freeze, "spec/fixtures/gpx/joensuu.gpx".freeze, "spec/fixtures/maps/london.png".freeze, "spec/fixtures/maps/thames.png".freeze, "spec/fixtures/vcr_cassettes/osm-london-fail.yml".freeze, "spec/fixtures/vcr_cassettes/osm-london.yml".freeze, "spec/fixtures/vcr_cassettes/osm-thames.yml".freeze, "spec/models/bounding_box_spec.rb".freeze, "spec/models/gpx_file_spec.rb".freeze, "spec/models/line_string_painter_spec.rb".freeze, "spec/models/map_spec.rb".freeze, "spec/models/null_painter_spec.rb".freeze, "spec/models/painter_spec.rb".freeze, "spec/spec_helper.rb".freeze] + s.files = ["Gemfile".freeze, "Gemfile.lock".freeze, "bin/mapstatic".freeze, "lib/mapstatic".freeze, "lib/mapstatic.rb".freeze, "lib/mapstatic/bounding_box.rb".freeze, "lib/mapstatic/cli.rb".freeze, "lib/mapstatic/conversion.rb".freeze, "lib/mapstatic/errors.rb".freeze, "lib/mapstatic/gpx_file.rb".freeze, "lib/mapstatic/map.rb".freeze, "lib/mapstatic/painter".freeze, "lib/mapstatic/painter.rb".freeze, "lib/mapstatic/painter/line_string_painter.rb".freeze, "lib/mapstatic/painter/null_painter.rb".freeze, "lib/mapstatic/renderer.rb".freeze, "lib/mapstatic/tile.rb".freeze, "lib/mapstatic/tile_source.rb".freeze, "lib/mapstatic/version.rb".freeze, "spec/fixtures".freeze, "spec/fixtures/gpx".freeze, "spec/fixtures/gpx/hervanta.gpx".freeze, "spec/fixtures/gpx/joensuu.gpx".freeze, "spec/fixtures/maps".freeze, "spec/fixtures/maps/london.png".freeze, "spec/fixtures/maps/thames.png".freeze, "spec/fixtures/vcr_cassettes".freeze, "spec/fixtures/vcr_cassettes/osm-london-fail.yml".freeze, "spec/fixtures/vcr_cassettes/osm-london.yml".freeze, "spec/fixtures/vcr_cassettes/osm-thames.yml".freeze, "spec/models".freeze, "spec/models/bounding_box_spec.rb".freeze, "spec/models/gpx_file_spec.rb".freeze, "spec/models/line_string_painter_spec.rb".freeze, "spec/models/map_spec.rb".freeze, "spec/models/null_painter_spec.rb".freeze, "spec/models/painter_spec.rb".freeze, "spec/spec_helper.rb".freeze] s.homepage = "https://github.com/crofty/mapstatic".freeze + s.licenses = ["MIT".freeze] s.rubygems_version = "3.0.3".freeze s.summary = "Static Map Generator".freeze @@ -22,30 +23,33 @@ Gem::Specification.new do |s| if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then s.add_runtime_dependency(%q.freeze, ["~> 4.9"]) s.add_runtime_dependency(%q.freeze, ["~> 1.3"]) - s.add_development_dependency(%q.freeze, [">= 0.19.0", "< 2.0"]) - s.add_development_dependency(%q.freeze, ["< 2.0"]) - s.add_development_dependency(%q.freeze, [">= 0"]) + s.add_runtime_dependency(%q.freeze, ["~> 1.10"]) + s.add_development_dependency(%q.freeze, ["~> 12"]) s.add_development_dependency(%q.freeze, ["~> 3"]) s.add_development_dependency(%q.freeze, ["~> 3"]) s.add_development_dependency(%q.freeze, ["~> 2"]) + s.add_development_dependency(%q.freeze, [">= 0.19.0", "< 2.0"]) + s.add_development_dependency(%q.freeze, ["< 2.0"]) else s.add_dependency(%q.freeze, ["~> 4.9"]) s.add_dependency(%q.freeze, ["~> 1.3"]) - s.add_dependency(%q.freeze, [">= 0.19.0", "< 2.0"]) - s.add_dependency(%q.freeze, ["< 2.0"]) - s.add_dependency(%q.freeze, [">= 0"]) + s.add_dependency(%q.freeze, ["~> 1.10"]) + s.add_dependency(%q.freeze, ["~> 12"]) s.add_dependency(%q.freeze, ["~> 3"]) s.add_dependency(%q.freeze, ["~> 3"]) s.add_dependency(%q.freeze, ["~> 2"]) + s.add_dependency(%q.freeze, [">= 0.19.0", "< 2.0"]) + s.add_dependency(%q.freeze, ["< 2.0"]) end else s.add_dependency(%q.freeze, ["~> 4.9"]) s.add_dependency(%q.freeze, ["~> 1.3"]) - s.add_dependency(%q.freeze, [">= 0.19.0", "< 2.0"]) - s.add_dependency(%q.freeze, ["< 2.0"]) - s.add_dependency(%q.freeze, [">= 0"]) + s.add_dependency(%q.freeze, ["~> 1.10"]) + s.add_dependency(%q.freeze, ["~> 12"]) s.add_dependency(%q.freeze, ["~> 3"]) s.add_dependency(%q.freeze, ["~> 3"]) s.add_dependency(%q.freeze, ["~> 2"]) + s.add_dependency(%q.freeze, [">= 0.19.0", "< 2.0"]) + s.add_dependency(%q.freeze, ["< 2.0"]) end end