├── 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 | [](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 |
26 |
27 | ```erb
28 | <%= static_map [{latitude: 37.7829, longitude: -122.4190}] %>
29 | ```
30 |
31 | Area map
32 |
33 |
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 |
--------------------------------------------------------------------------------