├── Gemfile ├── lib └── mapkick │ ├── static │ ├── version.rb │ ├── helper.rb │ ├── area_map.rb │ ├── map.rb │ └── base_map.rb │ └── static.rb ├── .gitignore ├── test ├── test_helper.rb └── map_test.rb ├── Rakefile ├── CHANGELOG.md ├── .github └── workflows │ └── build.yml ├── mapkick-static.gemspec ├── LICENSE.txt └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "minitest" 6 | gem "rake" 7 | -------------------------------------------------------------------------------- /lib/mapkick/static/version.rb: -------------------------------------------------------------------------------- 1 | module Mapkick 2 | module Static 3 | VERSION = "0.2.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.lock 10 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler.require(:default) 3 | require "minitest/autorun" 4 | 5 | ENV["MAPBOX_ACCESS_TOKEN"] ||= "pk.token" 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new do |t| 5 | t.pattern = "test/**/*_test.rb" 6 | end 7 | 8 | task default: :test 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.2.0 (2025-04-03) 2 | 3 | - Dropped support for Ruby < 3.2 4 | 5 | ## 0.1.1 (2023-05-03) 6 | 7 | - Fixed error with empty maps 8 | 9 | ## 0.1.0 (2023-04-26) 10 | 11 | - First release 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v5 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: 3.4 11 | bundler-cache: true 12 | - run: bundle exec rake test 13 | -------------------------------------------------------------------------------- /lib/mapkick/static/helper.rb: -------------------------------------------------------------------------------- 1 | module Mapkick 2 | module Static 3 | module Helper 4 | def static_map(data, **options) 5 | Mapkick::Static::Map.new(data, **options, view_context: self) 6 | end 7 | 8 | def static_area_map(data, **options) 9 | Mapkick::Static::AreaMap.new(data, **options, view_context: self) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /mapkick-static.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/mapkick/static/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "mapkick-static" 5 | spec.version = Mapkick::Static::VERSION 6 | spec.summary = "Create beautiful static maps with one line of Ruby" 7 | spec.homepage = "https://github.com/ankane/mapkick-static" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{lib}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.2" 17 | end 18 | -------------------------------------------------------------------------------- /lib/mapkick/static.rb: -------------------------------------------------------------------------------- 1 | # stdlib 2 | require "cgi/escape" 3 | require "json" 4 | require "uri" 5 | 6 | # maps 7 | require_relative "static/base_map" 8 | require_relative "static/area_map" 9 | require_relative "static/map" 10 | 11 | # modules 12 | require_relative "static/helper" 13 | require_relative "static/version" 14 | 15 | if defined?(ActiveSupport.on_load) 16 | ActiveSupport.on_load(:action_view) do 17 | include Mapkick::Static::Helper 18 | end 19 | 20 | ActiveSupport.on_load(:action_mailer) do 21 | include Mapkick::Static::Helper 22 | end 23 | end 24 | 25 | module Mapkick 26 | module Static 27 | class Error < StandardError; end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/mapkick/static/area_map.rb: -------------------------------------------------------------------------------- 1 | module Mapkick 2 | module Static 3 | class AreaMap < BaseMap 4 | private 5 | 6 | def generate_features(data, default_color) 7 | default_color ||= "#0090ff" 8 | 9 | data.map do |v| 10 | color = v["color"] || default_color 11 | { 12 | type: "Feature", 13 | # TODO round coordinates 14 | geometry: v["geometry"], 15 | properties: { 16 | "fill" => color, 17 | "fill-opacity" => 0.3, 18 | "stroke" => color, 19 | "stroke-width" => 1, 20 | "stroke-opacity" => 0.7 21 | } 22 | } 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/mapkick/static/map.rb: -------------------------------------------------------------------------------- 1 | module Mapkick 2 | module Static 3 | class Map < BaseMap 4 | private 5 | 6 | def generate_features(data, default_color) 7 | default_color ||= "#f84d4d" 8 | default_icon = nil 9 | 10 | data.group_by { |v| [v["color"] || default_color, v["x_icon"] || default_icon] }.map do |(color, icon), vs| 11 | geometry = { 12 | type: "MultiPoint", 13 | coordinates: vs.map { |v| row_coordinates(v).map { |vi| round_coordinate(vi) } } 14 | } 15 | 16 | properties = { 17 | "marker-color" => color 18 | } 19 | properties["marker-symbol"] = icon if icon 20 | 21 | { 22 | type: "Feature", 23 | geometry: geometry, 24 | properties: properties 25 | } 26 | end 27 | end 28 | 29 | def row_coordinates(row) 30 | [row["longitude"] || row["lng"] || row["lon"], row["latitude"] || row["lat"]] 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Andrew Kane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /test/map_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class MapTest < Minitest::Test 4 | include Mapkick::Static::Helper 5 | 6 | def setup 7 | @data = [{latitude: 1.23, longitude: 4.56}] 8 | end 9 | 10 | def test_static_map 11 | assert_map static_map(@data) 12 | end 13 | 14 | def test_static_map_empty 15 | assert_map static_map([]) 16 | end 17 | 18 | def test_static_area_map 19 | assert_map static_area_map([]) 20 | end 21 | 22 | def test_invalid_style 23 | error = assert_raises(ArgumentError) do 24 | static_map(@data, style: "custom") 25 | end 26 | assert_equal "Invalid style", error.message 27 | end 28 | 29 | def test_secret_token 30 | error = assert_raises(Mapkick::Static::Error) do 31 | static_map(@data, access_token: "sk.token") 32 | end 33 | assert_equal "Expected public access token", error.message 34 | end 35 | 36 | def test_invalid_token 37 | error = assert_raises(Mapkick::Static::Error) do 38 | static_map(@data, access_token: "token") 39 | end 40 | assert_equal "Invalid access token", error.message 41 | end 42 | 43 | def test_no_token 44 | with_no_token do 45 | error = assert_raises(Mapkick::Static::Error) do 46 | static_map(@data) 47 | end 48 | assert_equal "No access token", error.message 49 | end 50 | end 51 | 52 | def test_request_too_large 53 | data = 265.times.map { {latitude: rand, longitude: rand} } 54 | assert_output(nil, /URL exceeds 8192 byte limit/) do 55 | static_map(data) 56 | end 57 | end 58 | 59 | private 60 | 61 | def assert_map(map) 62 | assert_kind_of Mapkick::Static::BaseMap, map 63 | assert_match "https://api.mapbox.com/", map.url 64 | system "open", map.url if ENV["OPEN"] 65 | end 66 | 67 | def with_no_token 68 | previous_value = ENV["MAPBOX_ACCESS_TOKEN"] 69 | begin 70 | ENV.delete("MAPBOX_ACCESS_TOKEN") 71 | yield 72 | ensure 73 | ENV["MAPBOX_ACCESS_TOKEN"] = previous_value 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mapkick Static 2 | 3 | Create beautiful static maps with one line of Ruby. No more fighting with mapping libraries! 4 | 5 | [See it in action](#maps) 6 | 7 | :fire: For JavaScript maps, check out [Mapkick](https://chartkick.com/mapkick) 8 | 9 | [![Build Status](https://github.com/ankane/mapkick-static/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/mapkick-static/actions) 10 | 11 | ## Installation 12 | 13 | Add this line to your application’s Gemfile: 14 | 15 | ```ruby 16 | gem "mapkick-static" 17 | ``` 18 | 19 | Mapkick Static uses the [Mapbox Static Images API](https://docs.mapbox.com/api/maps/static-images/). [Create a Mapbox account](https://account.mapbox.com/auth/signup/) to get an access token and set `ENV["MAPBOX_ACCESS_TOKEN"]` in your environment. 20 | 21 | ## Maps 22 | 23 | Point map 24 | 25 | Point map 26 | 27 | ```erb 28 | <%= static_map [{latitude: 37.7829, longitude: -122.4190}] %> 29 | ``` 30 | 31 | Area map 32 | 33 | Area map 34 | 35 | ```erb 36 | <%= static_area_map [{geometry: {type: "Polygon", coordinates: ...}}] %> 37 | ``` 38 | 39 | ## Data 40 | 41 | Data can be an array 42 | 43 | ```erb 44 | <%= static_map [{latitude: 37.7829, longitude: -122.4190}] %> 45 | ``` 46 | 47 | ### Point Map 48 | 49 | Use `latitude` or `lat` for latitude and `longitude`, `lon`, or `lng` for longitude 50 | 51 | You can specify a color for each data point 52 | 53 | ```ruby 54 | { 55 | latitude: ..., 56 | longitude: ..., 57 | color: "#f84d4d" 58 | } 59 | ``` 60 | 61 | ### Area Map 62 | 63 | Use `geometry` with a GeoJSON `Polygon` or `MultiPolygon` 64 | 65 | You can specify a color for each data point 66 | 67 | ```ruby 68 | { 69 | geometry: {type: "Polygon", coordinates: ...}, 70 | color: "#0090ff" 71 | } 72 | ``` 73 | 74 | ## Options 75 | 76 | Width and height 77 | 78 | ```erb 79 | <%= static_map data, width: 800, height: 500 %> 80 | ``` 81 | 82 | Alt text 83 | 84 | ```erb 85 | <%= static_map data, alt: "Map of ..." %> 86 | ``` 87 | 88 | Marker color 89 | 90 | ```erb 91 | <%= static_map data, markers: {color: "#f84d4d"} %> 92 | ``` 93 | 94 | Map style 95 | 96 | ```erb 97 | <%= static_map data, style: "mapbox/outdoors-v12" %> 98 | ``` 99 | 100 | ## History 101 | 102 | View the [changelog](https://github.com/ankane/mapkick-static/blob/master/CHANGELOG.md) 103 | 104 | ## Contributing 105 | 106 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 107 | 108 | - [Report bugs](https://github.com/ankane/mapkick-static/issues) 109 | - Fix bugs and [submit pull requests](https://github.com/ankane/mapkick-static/pulls) 110 | - Write, clarify, or fix documentation 111 | - Suggest or add new features 112 | 113 | To get started with development: 114 | 115 | ```sh 116 | git clone https://github.com/ankane/mapkick-static.git 117 | cd mapkick-static 118 | bundle install 119 | bundle exec rake test 120 | ``` 121 | -------------------------------------------------------------------------------- /lib/mapkick/static/base_map.rb: -------------------------------------------------------------------------------- 1 | module Mapkick 2 | module Static 3 | class BaseMap 4 | attr_reader :url, :url_2x 5 | 6 | def initialize(data, width: 800, height: 500, markers: {}, style: "mapbox/streets-v12", alt: "Map", access_token: nil, view_context: nil) 7 | @width = width.to_i 8 | @height = height.to_i 9 | @alt = alt 10 | @view_context = view_context 11 | 12 | prefix = "https://api.mapbox.com/styles/v1" 13 | style = set_style(style) 14 | geojson = create_geojson(data, markers) 15 | overlay = "geojson(#{CGI.escape(JSON.generate(geojson))})" 16 | viewport = set_viewport(geojson) 17 | size = "%dx%d" % [@width.to_i, @height.to_i] 18 | query = set_query(access_token, viewport) 19 | 20 | url = "#{prefix}/#{style}/static/#{overlay}/#{viewport}/#{size}" 21 | @url = "#{url}?#{query}" 22 | @url_2x = "#{url}@2x?#{query}" 23 | 24 | check_request_size 25 | end 26 | 27 | def to_s 28 | @view_context.image_tag(url, alt: @alt, style: image_style, srcset: "#{url} 1x, #{url_2x} 2x") 29 | end 30 | 31 | private 32 | 33 | def set_style(style) 34 | style = style.delete_prefix("mapbox://styles/") 35 | if style.count("/") != 1 36 | raise ArgumentError, "Invalid style" 37 | end 38 | style.split("/", 2).map { |v| CGI.escape(v) }.join("/") 39 | end 40 | 41 | def create_geojson(data, markers) 42 | data = data.map { |v| v.transform_keys(&:to_s) } 43 | default_color = markers.transform_keys(&:to_s)["color"] 44 | { 45 | type: "FeatureCollection", 46 | features: generate_features(data, default_color) 47 | } 48 | end 49 | 50 | def set_viewport(geojson) 51 | if geojson[:features].empty? 52 | "0,0,0" 53 | elsif geojson[:features].size == 1 && (geometry = geojson[:features][0][:geometry]) && geometry&.[](:type) == "MultiPoint" && geometry[:coordinates].size == 1 54 | coordinates = geometry[:coordinates][0] 55 | zoom = 15 56 | "%f,%f,%d" % [round_coordinate(coordinates[0].to_f), round_coordinate(coordinates[1].to_f), zoom.to_i] 57 | else 58 | "auto" 59 | end 60 | end 61 | 62 | def set_query(access_token, viewport) 63 | params = {} 64 | params[:access_token] = check_access_token(access_token || ENV["MAPBOX_ACCESS_TOKEN"]) 65 | if viewport == "auto" 66 | params[:padding] = 40 67 | end 68 | URI.encode_www_form(params) 69 | end 70 | 71 | def check_access_token(access_token) 72 | if !access_token 73 | raise Error, "No access token" 74 | elsif access_token.start_with?("sk.") 75 | # can bypass with string keys 76 | # but should help prevent common errors 77 | raise Error, "Expected public access token" 78 | elsif !access_token.start_with?("pk.") 79 | raise Error, "Invalid access token" 80 | end 81 | access_token 82 | end 83 | 84 | # round to reduce URL size 85 | def round_coordinate(point) 86 | point.round(7) 87 | end 88 | 89 | # https://docs.mapbox.com/api/overview/#url-length-limits 90 | def check_request_size 91 | if @url_2x.bytesize > 8192 92 | warn "[mapkick-static] URL exceeds 8192 byte limit of API (#{@url_2x.bytesize} bytes)" 93 | end 94 | end 95 | 96 | def image_style 97 | "width: %dpx; height: %dpx;" % [@width.to_i, @height.to_i] 98 | end 99 | end 100 | end 101 | end 102 | --------------------------------------------------------------------------------