├── .gitignore ├── .rubocop.yml ├── .rubocop_todo.yml ├── Gemfile ├── LICENSE.md ├── README.md ├── bin └── mapnik_legendary ├── examples ├── openstreetmap-carto-legend.yml └── tracks.yml ├── lib ├── mapnik_legendary.rb └── mapnik_legendary │ ├── docwriter.rb │ ├── feature.rb │ ├── geometry.rb │ ├── part.rb │ ├── tags.rb │ └── version.rb └── mapnik_legendary.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | symbols 2 | *.png 3 | Gemfile.lock 4 | output/ 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by `rubocop --auto-gen-config` 2 | # on 2015-08-13 13:48:05 +0100 using RuboCop version 0.32.0. 3 | # The point is for the user to remove these configuration records 4 | # one by one as the offenses are removed from the code base. 5 | # Note that changes in the inspected code, or installation of new 6 | # versions of RuboCop, may require this file to be generated again. 7 | 8 | # Offense count: 3 9 | Metrics/AbcSize: 10 | Max: 89 11 | 12 | # Offense count: 1 13 | Metrics/CyclomaticComplexity: 14 | Max: 11 15 | 16 | # Offense count: 24 17 | # Configuration parameters: AllowURI, URISchemes. 18 | Metrics/LineLength: 19 | Max: 161 20 | 21 | # Offense count: 3 22 | # Configuration parameters: CountComments. 23 | Metrics/MethodLength: 24 | Max: 64 25 | 26 | # Offense count: 1 27 | Metrics/PerceivedComplexity: 28 | Max: 12 29 | 30 | # Offense count: 2 31 | Style/Documentation: 32 | Enabled: false 33 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Andy Allan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mapnik Legendary 2 | 3 | [Mapnik Legendary](https://github.com/gravitystorm/mapnik-legendary) is a small utility to help with generating legends (aka map keys) from Mapnik stylesheets. You describe in a config file which attributes, and which zoom level(s) you want an image for, and it reads the stylesheet and spits out .png files. It uses the ruby-mapnik bindings to load the stylesheets and mess around with the datasources, so you don't actually need any of the shapefiles or database connections to make this work. 4 | 5 | ## Requirements 6 | 7 | * [Ruby-Mapnik bindings](https://github.com/mapnik/Ruby-Mapnik) >= 0.2.0 8 | * mapnik 2.x and ruby (both required for Ruby-Mapnik) 9 | 10 | ## Installation 11 | 12 | You can install the gem from rubygems: 13 | 14 | `gem install mapnik_legendary` 15 | 16 | Alternatively, you can add the gem to your project's Gemfile 17 | 18 | `gem mapnik_legendary` 19 | 20 | ## Running 21 | 22 | For full options, run 23 | 24 | `mapnik_legendary -h` 25 | 26 | ## Examples 27 | 28 | `mapnik_legendary examples/openstreetmap-carto-legend.yml osm-carto.xml` 29 | 30 | See [examples/openstreetmap-carto-legend.yml](examples/openstreetmap-carto-legend.yml) 31 | -------------------------------------------------------------------------------- /bin/mapnik_legendary: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # encoding: utf-8 3 | 4 | require 'mapnik_legendary' 5 | require 'mapnik_legendary/version' 6 | require 'optparse' 7 | require 'ostruct' 8 | 9 | options = OpenStruct.new 10 | options.overwrite = false 11 | 12 | optparse = OptionParser.new do |opts| 13 | opts.banner = 'Usage: mapnik_legendary [options] ' 14 | 15 | opts.on('-z', '--zoom Z', Float, 'Override the zoom level stated in the legend file') do |z| 16 | options.zoom = z 17 | end 18 | 19 | opts.on('--overwrite', 'Overwrite existing output files') do |o| 20 | options.overwrite = o 21 | end 22 | 23 | opts.on_tail('-h', '--help', 'Show this message') do 24 | puts opts 25 | exit 26 | end 27 | 28 | opts.on_tail('--version', 'Show version') do 29 | puts MapnikLegendary::VERSION 30 | exit 31 | end 32 | end 33 | optparse.parse! 34 | 35 | # Check required conditions 36 | if ARGV.length != 2 37 | optparse.abort('Error: Two input filenames required') 38 | exit(-1) 39 | end 40 | 41 | legend_file = ARGV[0] 42 | map_file = ARGV[1] 43 | 44 | MapnikLegendary.generate_legend(legend_file, map_file, options) 45 | -------------------------------------------------------------------------------- /examples/openstreetmap-carto-legend.yml: -------------------------------------------------------------------------------- 1 | width: 100 2 | height: 60 3 | background: "transparent" 4 | features: 5 | - 6 | name: "motorway" 7 | type: "linestring" 8 | tags: 9 | feature: "highway_motorway" 10 | highway: "motorway" 11 | layers: 12 | - "roads-casing" 13 | - "roads-fill" 14 | zoom: 18 15 | - 16 | name: "motorway" 17 | type: "linestring" 18 | tags: 19 | feature: "highway_motorway" 20 | highway: "motorway" 21 | layers: 22 | - "roads-casing" 23 | - "roads-fill" 24 | zoom: 15 25 | - 26 | name: "motorway" 27 | type: "linestring" 28 | tags: 29 | feature: "highway_motorway" 30 | highway: "motorway" 31 | layers: 32 | - "roads-low-zoom" 33 | zoom: 6 34 | - 35 | name: "residential" 36 | type: "linestring" 37 | tags: 38 | feature: "highway_residential" 39 | highway: "residential" 40 | layers: 41 | - "roads-casing" 42 | - "roads-fill" 43 | zoom: 15 44 | - 45 | name: "footway" 46 | type: "linestring" 47 | tags: 48 | feature: "highway_footway" 49 | highway: "footway" 50 | layers: 51 | - "roads-casing" 52 | - "roads-fill" 53 | zoom: 15 54 | - 55 | name: "pub" 56 | type: "point" 57 | tags: 58 | amenity: "pub" 59 | layers: 60 | - "amenity-points" 61 | zoom: 16 62 | - 63 | name: "park" 64 | type: "polygon" 65 | tags: 66 | feature: "leisure_park" 67 | layers: 68 | - "landcover" 69 | zoom: 15 70 | extra_tags: 71 | - "feature" 72 | - "cycleway" 73 | - "service" 74 | - "tunnel" 75 | - "bicycle" 76 | - "bridge" 77 | - "construction" 78 | - "highway" 79 | - "foot" 80 | - "horse" 81 | - "tracktype" 82 | - "access" 83 | - "historic" 84 | - "leisure" 85 | - "lock" 86 | - "man_made" 87 | - "religion" 88 | - "shop" 89 | - "tourism" 90 | - "waterway" 91 | -------------------------------------------------------------------------------- /examples/tracks.yml: -------------------------------------------------------------------------------- 1 | width: 100 2 | height: 60 3 | background: "#f2efe9" 4 | features: 5 | - 6 | name: "track-no-tracktype" 7 | type: "linestring" 8 | tags: 9 | feature: "highway_track" 10 | tracktype: null 11 | layers: 12 | - "roads-fill" 13 | zoom: 15 14 | - 15 | name: "track-grade1" 16 | type: "linestring" 17 | tags: 18 | feature: "highway_track" 19 | tracktype: "grade1" 20 | layers: 21 | - "roads-fill" 22 | zoom: 15 23 | - 24 | name: "track-grade2" 25 | type: "linestring" 26 | tags: 27 | feature: "highway_track" 28 | tracktype: "grade2" 29 | layers: 30 | - "roads-fill" 31 | zoom: 15 32 | - 33 | name: "track-grade3" 34 | type: "linestring" 35 | tags: 36 | feature: "highway_track" 37 | tracktype: "grade3" 38 | layers: 39 | - "roads-fill" 40 | zoom: 15 41 | - 42 | name: "track-grade4" 43 | type: "linestring" 44 | tags: 45 | feature: "highway_track" 46 | tracktype: "grade4" 47 | layers: 48 | - "roads-fill" 49 | zoom: 15 50 | - 51 | name: "track-grade5" 52 | type: "linestring" 53 | tags: 54 | feature: "highway_track" 55 | tracktype: "grade5" 56 | layers: 57 | - "roads-fill" 58 | zoom: 15 59 | extra_tags: 60 | - "access" 61 | - "bicycle" 62 | - "construction" 63 | - "feature" 64 | - "foot" 65 | - "horse" 66 | - "service" 67 | -------------------------------------------------------------------------------- /lib/mapnik_legendary.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'mapnik' 4 | require 'yaml' 5 | require 'fileutils' 6 | require 'logger' 7 | 8 | require 'mapnik_legendary/feature' 9 | require 'mapnik_legendary/docwriter' 10 | 11 | module MapnikLegendary 12 | DEFAULT_ZOOM = 17 13 | 14 | def self.generate_legend(legend_file, map_file, options) 15 | log = Logger.new(STDERR) 16 | 17 | legend = YAML.load(File.read(legend_file)) 18 | 19 | if legend.key?('fonts_dir') 20 | Mapnik::FontEngine.register_fonts(legend['fonts_dir']) 21 | end 22 | 23 | map = Mapnik::Map.from_xml(File.read(map_file), false, File.dirname(map_file)) 24 | map.width = legend['width'] 25 | map.height = legend['height'] 26 | map.srs = '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0.0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over' 27 | 28 | if legend.key?('background') 29 | map.background = Mapnik::Color.new(legend['background']) 30 | end 31 | 32 | layer_styles = [] 33 | map.layers.each do |l| 34 | layer_styles.push(name: l.name, 35 | style: l.styles.map { |s| s } # get them out of the collection 36 | ) 37 | end 38 | 39 | docs = Docwriter.new 40 | docs.image_width = legend['width'] 41 | 42 | legend['features'].each_with_index do |feature, idx| 43 | # TODO: use a proper csv library rather than .join(",") ! 44 | zoom = options.zoom || feature['zoom'] || DEFAULT_ZOOM 45 | feature = Feature.new(feature, zoom, map, legend['extra_tags']) 46 | map.zoom_to_box(feature.envelope) 47 | map.layers.clear 48 | 49 | feature.parts.each do |part| 50 | if part.layers.nil? 51 | log.warn "Can't find any layers defined for a part of #{feature.name}" 52 | next 53 | end 54 | part.layers.each do |layer_name| 55 | ls = layer_styles.select { |l| l[:name] == layer_name } 56 | if ls.empty? 57 | log.warn "Can't find #{layer_name} in the xml file" 58 | next 59 | else 60 | ls.each do |layer_style| 61 | l = Mapnik::Layer.new(layer_name, map.srs) 62 | datasource = Mapnik::Datasource.create(type: 'csv', inline: part.to_csv) 63 | l.datasource = datasource 64 | layer_style[:style].each do |style_name| 65 | l.styles << style_name 66 | end 67 | map.layers << l 68 | end 69 | end 70 | end 71 | end 72 | 73 | FileUtils.mkdir_p('output') 74 | # map.zoom_to_box(Mapnik::Envelope.new(0,0,1,1)) 75 | id = feature.name || "legend-#{idx}" 76 | id.gsub!(/[^\w\s_-]+/, '') 77 | filename = File.join(Dir.pwd, 'output', "#{id}-#{zoom}.png") 78 | i = 0 79 | while File.exist?(filename) && !options.overwrite 80 | i += 1 81 | filename = File.join(Dir.pwd, 'output', "#{id}-#{zoom}-#{i}.png") 82 | end 83 | begin 84 | map.render_to_file(filename, 'png256:t=2') 85 | rescue RuntimeError => e 86 | r = /^CSV Plugin: no attribute '(?[^']*)'/ 87 | match_data = r.match(e.message) 88 | if match_data 89 | log.error "'#{match_data[:key]}' is a key needed for the '#{feature.name}' feature." 90 | log.error "Try adding '#{match_data[:key]}' to the extra_tags list." 91 | next 92 | else 93 | raise e 94 | end 95 | end 96 | docs.add File.basename(filename), feature.description 97 | end 98 | 99 | f = File.open(File.join(Dir.pwd, 'output', 'docs.html'), 'w') 100 | f.write(docs.to_html) 101 | f.close 102 | 103 | title = legend.key?('title') ? legend['title'] : 'Legend' 104 | docs.to_pdf(File.join(Dir.pwd, 'output', 'legend.pdf'), title) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/mapnik_legendary/docwriter.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'prawn/table' 3 | require 'prawn' 4 | 5 | module MapnikLegendary 6 | class Docwriter 7 | attr_accessor :image_width 8 | 9 | def initialize 10 | @entries = [] 11 | @image_width = 100 12 | end 13 | 14 | def add(image, description) 15 | @entries << [image, description] 16 | end 17 | 18 | def to_html 19 | doc = '' + "\n" 20 | @entries.each do |entry| 21 | doc += "\n" 22 | end 23 | doc += '
#{entry[1]}
' + "\n" 24 | doc 25 | end 26 | 27 | def to_pdf(filename, title) 28 | entries = @entries 29 | image_width = @image_width 30 | Prawn::Document.generate(filename, page_size: 'A4') do 31 | font_families.update( 32 | 'Ubuntu' => { bold: '/usr/share/fonts/truetype/ubuntu-font-family/Ubuntu-B.ttf', 33 | italic: '/usr/share/fonts/truetype/ubuntu-font-family/Ubuntu-RI.ttf', 34 | normal: '/usr/share/fonts/truetype/ubuntu-font-family/Ubuntu-R.ttf' } 35 | ) 36 | font 'Ubuntu' 37 | font_size 12 38 | text title, style: :bold, size: 24, align: :center 39 | move_down(40) 40 | data = [] 41 | image_scale = 0.5 42 | entries.each do |entry| 43 | data << { image: File.join(File.dirname(filename), entry[0]), 44 | scale: image_scale, 45 | position: :center, 46 | vposition: :center } 47 | data << entry[1] 48 | end 49 | 50 | columns = image_width >= 200 ? 2 : 3 51 | 52 | table(data.each_slice(columns * 2).to_a, cell_style: { border_width: 0.1 }) do 53 | (0..columns - 1).each do |n| 54 | column(n * 2).width = image_width * image_scale 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/mapnik_legendary/feature.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'mapnik_legendary/part' 4 | 5 | module MapnikLegendary 6 | # A feature has a name, description, and one or more parts holding geometries, tags and layers 7 | class Feature 8 | attr_reader :name, :parts, :description 9 | 10 | def initialize(feature, zoom, map, extra_tags) 11 | @name = feature['name'] 12 | @description = feature.key?('description') ? feature['description'] : @name.capitalize 13 | @parts = [] 14 | if feature.key? 'parts' 15 | feature['parts'].each do |part| 16 | @parts << Part.new(part, zoom, map, extra_tags) 17 | end 18 | else 19 | @parts << Part.new(feature, zoom, map, extra_tags) 20 | end 21 | end 22 | 23 | def envelope 24 | @parts.first.geom.envelope 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/mapnik_legendary/geometry.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module MapnikLegendary 4 | # A wkt-based geometry that can be used for the legend feature. 5 | class Geometry 6 | def initialize(type, zoom, map) 7 | proj = Mapnik::Projection.new(map.srs) 8 | width_of_world_in_pixels = 2**zoom * 256 9 | width_of_world_in_metres = proj.forward(Mapnik::Coord2d.new(180, 0)).x - proj.forward(Mapnik::Coord2d.new(-180, 0)).x 10 | width_of_image_in_metres = map.width.to_f / width_of_world_in_pixels * width_of_world_in_metres 11 | height_of_image_in_metres = map.height.to_f / width_of_world_in_pixels * width_of_world_in_metres 12 | 13 | @max_x = width_of_image_in_metres 14 | @max_y = height_of_image_in_metres 15 | @min_x = 0 16 | @min_y = 0 17 | 18 | @geom = case type 19 | when 'point' then "POINT(#{@max_x / 2} #{@max_y / 2})" 20 | when 'point75' then "POINT(#{@max_x * 0.5} #{@max_y * 0.75})" 21 | when 'polygon' then "POLYGON((0 0, #{@max_x} 0, #{@max_x} #{@max_y}, 0 #{@max_y}, 0 0))" 22 | when 'linestring-with-gap' then "MULTILINESTRING((0 0, #{@max_x * 0.45} #{@max_y * 0.45}),(#{@max_x * 0.55} #{@max_y * 0.55},#{@max_x} #{@max_y}))" 23 | when 'polygon-with-hole' then "POLYGON((#{0.7 * @max_x} #{0.2 * @max_y}, #{0.9 * @max_x} #{0.9 * @max_y}" \ 24 | ", #{0.3 * @max_x} #{0.8 * @max_y}, #{0.2 * @max_x} #{0.4 * @max_y}" \ 25 | ", #{0.7 * @max_y} #{0.2 * @max_y}),( #{0.4 * @max_x} #{0.6 * @max_y}" \ 26 | ", #{0.7 * @max_x} #{0.7 * @max_y}, #{0.6 * @max_x} #{0.4 * @max_y}" \ 27 | ", #{0.4 * @max_x} #{0.6 * @max_y}))" 28 | else "LINESTRING(0 0, #{@max_x} #{@max_y})" 29 | end 30 | end 31 | 32 | def to_csv 33 | %("#{@geom}") 34 | end 35 | 36 | def envelope 37 | Mapnik::Envelope.new(@min_x, @min_y, @max_x, @max_y) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/mapnik_legendary/part.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'mapnik_legendary/tags' 4 | require 'mapnik_legendary/geometry' 5 | 6 | module MapnikLegendary 7 | # A part is a combination of tags, geometry and layers. 8 | class Part 9 | attr_reader :tags, :geom, :layers 10 | 11 | def initialize(h, zoom, map, extra_tags) 12 | @tags = Tags.merge_nulls(h['tags'], extra_tags) 13 | @geom = Geometry.new(h['type'], zoom, map) 14 | if h['layer'] 15 | @layers = [h['layer']] 16 | else 17 | @layers = h['layers'] 18 | end 19 | end 20 | 21 | def to_csv 22 | csv = '' 23 | csv << @tags.keys.push('wkt').join(',') + "\n" 24 | csv << @tags.values.push(@geom.to_csv).join(',') + "\n" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/mapnik_legendary/tags.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module MapnikLegendary 4 | # convenience methods for handling tags 5 | class Tags 6 | # Merges a list of extra keys into an existing hash of tags. 7 | # The extra keys are each given a null value. 8 | def self.merge_nulls(tags, extras) 9 | tags = {} if tags.nil? 10 | extras = [] if extras.nil? 11 | Hash[extras.map { |t| [t, nil] }].merge(tags) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/mapnik_legendary/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module MapnikLegendary 4 | VERSION = '0.4.0' 5 | end 6 | -------------------------------------------------------------------------------- /mapnik_legendary.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/mapnik_legendary/version', __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'mapnik_legendary' 5 | s.version = MapnikLegendary::VERSION 6 | s.author = 'Andy Allan' 7 | s.email = 'andy@gravitystorm.co.uk' 8 | s.homepage = 'https://github.com/gravitystorm/mapnik-legendary' 9 | s.summary = 'Create legends (map keys) for mapnik stylesheets' 10 | s.description = 'Creating legends by hand is tedious. This software allows you to generate them automatically.' 11 | s.default_executable = 'bin/mapnik_legendary' 12 | s.files = Dir['{lib}/**/*.rb', 'bin/*', '*.md'] 13 | s.license = 'MIT' 14 | 15 | s.add_runtime_dependency 'mapnik', ['>= 0.2.0'] 16 | s.add_runtime_dependency 'prawn' 17 | s.add_runtime_dependency 'prawn-table' 18 | s.add_development_dependency 'rspec' 19 | end 20 | --------------------------------------------------------------------------------