├── README.md ├── bin └── glug ├── glug.gemspec └── lib ├── glug.rb └── glug ├── condition.rb ├── extensions.rb ├── layer.rb └── stylesheet.rb /README.md: -------------------------------------------------------------------------------- 1 | # Glug 2 | 3 | Text-based markup for MapLibre and Mapbox GL. 4 | 5 | Glug is a compact markup 'language' that compiles to GL JSON styles. It's implemented as a Ruby Domain-Specific Language (DSL), with all the flexibility that affords. 6 | 7 | Unlike CartoCSS and MapCSS, Glug does not cascade rules as standard. Cascading can produce a large number of rules, which is bad for mobile performance, and can make styles difficult to manage. Instead, Glug encourages concise styling by nesting layer definitions, with limited cascading as an option. 8 | 9 | Glug is a compiler. You should use it to generate JSON, then serve that JSON with your maps. Don't use Glug on the fly in production. 10 | 11 | ## A simple Glug stylesheet 12 | 13 | ```ruby 14 | version 8 15 | name "My first stylesheet" 16 | source :osm_data, type: 'vector', url: 'http://my-server.com/osm.tilejson' 17 | 18 | layer(:roads, zoom: 10..13, source: :osm_data) { 19 | line_width 6 20 | line_color 0x888888 21 | on(highway=='motorway', highway=='motorway_link') { line_color :blue } 22 | on(highway=='trunk', highway=='trunk_link') { line_color :green } 23 | on(highway=='primary', highway=='primary_link') { line_color :red } 24 | on(highway=='secondary') { line_color :orange } 25 | on(highway=='residential') { line_width 4 } 26 | } 27 | ``` 28 | 29 | ## Installation and running 30 | 31 | `gem install glug` 32 | 33 | Run glug from the command line: 34 | 35 | `glug my_stylesheet.glug > my_stylesheet.json` 36 | 37 | Use Glug from Ruby: 38 | 39 | ```ruby 40 | require 'glug' 41 | 42 | json = Glug::Stylesheet.new { 43 | version 8 44 | center [0.5,53] 45 | }.to_json 46 | ``` 47 | 48 | You should refer to the [Mapbox GL style documentation](https://www.mapbox.com/mapbox-gl-style-spec/) to understand GL styles and their properties. This README only explains how Glug expresses those properties. 49 | 50 | ## Stylesheet and sources 51 | 52 | Stylesheet-wide properties are defined simply: 53 | 54 | ```ruby 55 | version 8 56 | center [-1.3,51.5] 57 | ``` 58 | 59 | Sources are defined as hashes, with the source name specified as a symbol: 60 | 61 | ```ruby 62 | source :mapbox_streets, type: 'vector', 63 | url: 'mapbox://mapbox.mapbox-streets-v5', default: true 64 | ``` 65 | 66 | Note the `default: true` extension. This registers the source as the default for your style, so you don't have to specify it in every layer. 67 | 68 | ## Layers - the basics 69 | 70 | Layers are the meat of the styling, where Glug does most of its work. 71 | 72 | ### Creating a layer 73 | 74 | You create a layer like this: 75 | 76 | ```ruby 77 | layer(:water, zoom: 5..13, source: :osm_data) { 78 | # Style definitions go here... 79 | } 80 | ``` 81 | 82 | The layer call begins with the layer id (`:water`) and then any additional layer-wide properties (source, source_layer, metadata, interactive). If no source is specified, the default will be used. If no source_layer is specified, the layer id will be used - so in this case, Glug would assume a source_layer of 'water'. 83 | 84 | Zoom levels are always specified as Ruby ranges (`5..13`) rather than separate minzoom/maxzoom properties. 85 | 86 | ### Style definitions 87 | 88 | Style properties are defined as you'd expect: 89 | 90 | ```ruby 91 | line_width 5 92 | line_color 0xFF07C3 93 | ``` 94 | 95 | Glug will automatically create the 'type' property for you based on the styles you define. Use 'line_width' or 'line_color', and Glug will set 'type' to 'line'. GL styles don't allow you to mix different types (e.g. lines and fills) within one layer. 96 | 97 | Use underscores in property names where the GL style spec has a hyphen, so 'line_color' rather than 'line-color'. 98 | 99 | When defining colours, note that Ruby specifies hex colours like so: `0xC38291`. This means you need to specify all six digits of a hex colour, so `0xCC3388` rather than '#C38'. You can avoid this by supplying a string instead: `"#C38"`. 100 | 101 | You can use either symbols (`:blue`) or strings (`"blue"`): both will be written out as strings. 102 | 103 | ### Filters and expressions 104 | 105 | Glug wraps GL styles' powerful [expressions](https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/) in a more familiar format, so you can easily make your styles react to tags/attributes. At its simplest, Glug allows you to add a test like this: 106 | 107 | ```ruby 108 | filter highway=='primary' 109 | ``` 110 | 111 | Adding this to a layer will mean the style only applies to primary roads. It uses Ruby's `==` test, not the single '=' you might expect from CSS-based language. You can use other operators, including numeric ones: 112 | 113 | ```ruby 114 | filter population<30000 115 | ``` 116 | and 'in'/'not_in' lists: 117 | 118 | ```ruby 119 | filter amenity.in('pub','cafe','restaurant') 120 | ``` 121 | 122 | You can separate tests with commas to match multiple choices: 123 | 124 | ``` 125 | filter amenity=='pub', tourism=='hotel' 126 | ``` 127 | 128 | You can join tests together with `&` (and) and `|` (or) operators: 129 | 130 | ```ruby 131 | filter (place=='town') & (population>100000) 132 | ``` 133 | 134 | You can combine several such operators, but be liberal with parentheses to make the precedence clear: 135 | 136 | ```ruby 137 | filter ( (place=='town') & (population>100000) ) | (place=='city') 138 | ``` 139 | Alternatively, you can also express multiple choices with the `any[]` and `all[]` operators: 140 | 141 | ```ruby 142 | filter any[amenity=='pub', tourism=='hotel', amenity=='restaurant'] 143 | ``` 144 | 145 | ### Using expressions as properties 146 | 147 | You can also use expressions to set GL properties programmatically. For example, to set the colour of a circle based on the 'temperature' tag in the vector tile: 148 | 149 | ```ruby 150 | circle_color rgb(temperature, 0, temperature/2) 151 | ``` 152 | 153 | If you're following along with the GL [expressions spec](https://docs.mapbox.com/mapbox-gl-js/style-spec/expressions/), GL JSON operators are expressed as an array, and in Glug we write them as a operator name ('rgb') followed by the arguments in parentheses. 154 | 155 | A more complex example: 156 | 157 | ```ruby 158 | fill_color let('density', population/sqkm) << 159 | interpolate([:linear], zoom(), 160 | 8, interpolate([:linear], var('density'), 274, to_color("#edf8e9"), 1551, to_color("#006d2c")), 161 | 10, interpolate([:linear], var('density'), 274, to_color("#eff3ff"), 1551, to_color("#08519c")) 162 | ) 163 | ``` 164 | 165 | Several operators can also be used as dot (postfix) methods. For example, `name.length` will return the length of the name tag; `name.upcase` will return it in upper case; and `in` can be used with a list of values, e.g. `ref.in("M1","M5","M6")`. For the list of operators where this applies, see DOT_METHODS in condition.rb. 166 | 167 | Note the following: 168 | 169 | * You don't need to use the `get` operator - you can just write the tag name, e.g. `temperature`. (You can still use `get` in case of a clash with a reserved word.) 170 | * For the GL operator 'format', write `string_format` instead; for 'id', write `feature_id`; for 'case', write `case_when`; for the '!' operator, write `_!`. (These are Ruby reserved words or used elsewhere in Glug.) 171 | * Where a GL operator has a dash, write it with an underscore (e.g. `to_color`). 172 | * Arrays can be accessed in standard bracketed subscript notation, e.g. `colours[5]` (compiled to 'at' in the GL style). 173 | * Within colour operators, you'll need to write colours as strings (e.g. "#FF0000") rather than Ruby hex values. 174 | * To concatenate two operators (often where the first is `let`), use `<<`. 175 | * You may still need to use `literal(1,2,3)` to write an array or object value. 176 | 177 | 178 | ## Sublayers and cascading 179 | 180 | ### Sublayers 181 | 182 | Filter expressions come into their own when defining sublayers. 183 | 184 | A sublayer inherits all the properties of its parent layer, and adds more, if a test is fulfilled. For example, if you wanted to show all roads 2px wide, but motorways 4px wide: 185 | 186 | ```ruby 187 | layer(:roads) { 188 | line_color :black 189 | line_width 2 190 | on(highway=='motorway') { line_width 4 } 191 | } 192 | ``` 193 | 194 | Sublayers are introduced with the `on` instruction, which expects either a zoom range, an expression, or both. The following are all valid: 195 | 196 | ```ruby 197 | on(highway=='motorway') { line_width 4 } 198 | on(8..12, highway=='motorway') { line_color :blue } 199 | on(3..6) { line_width 2 } 200 | on(8, highway=='motorway', oneway='yes') { line-width 2 } 201 | ``` 202 | 203 | Sublayers can be nested: 204 | 205 | ```ruby 206 | on(3..6) { 207 | line_width 2 208 | on(highway=='motorway') { line-color :blue } 209 | } 210 | ``` 211 | Do not add a space between `on` and the parentheses. If your filter breaks, add more parentheses. 212 | 213 | Sometimes, you may wish to only generate the sublayers, and suppress the partially unstyled parent layer. You can achieve this with the `suppress` instruction: 214 | 215 | ```ruby 216 | layer(:roads) { 217 | line_width 4 218 | on(highway=='trunk') { line_color :green } 219 | on(highway=='primary') { line_color :blue } 220 | on(highway=='secondary') { line_color :orange } 221 | suppress 222 | } 223 | ``` 224 | 225 | Sublayers have no special meaning in GL styles; they are normal layers like any other. Glug unwraps the 'inherited' properties and creates a layer accordingly. Points to note: 226 | 227 | * Glug names sublayers automatically: the first sublayer of `roads` will be `roads__1`. If you want to give a sublayer an explicit layer id, write `id :minor_roads`. 228 | * If two layers share a source, filter, zoom levels, type, and certain ('layout') properties, the GL renderer can optimise drawing by reusing the same definition. Glug does this invisibly so you don't need to specify it in your style. 229 | * Layer ordering follows the order of your stylesheet. 230 | * Nested zoom levels simply overwrite their 'parents', so a zoom 7 nested within a zoom 3..6 will still render at zoom 7. 231 | 232 | ### Cascading 233 | 234 | An intentionally limited form of cascading is provided: 235 | 236 | ```ruby 237 | layer(:roads) { 238 | line_width 4 239 | 240 | cascade(motor_vehicle=='no') { line_width 2 } 241 | uncascaded(motor_vehicle!='no') 242 | 243 | on(highway=='trunk') { line_color :green } 244 | on(highway=='primary') { line_color :blue } 245 | suppress 246 | } 247 | ``` 248 | 249 | For each sublayer, a cascaded variant is applied with the `motor_vehicle=='no'` test. The uncascaded sublayer gets an extra condition, too, to avoid both versions being drawn when `motor_vehicle=='no'`. 250 | 251 | The result is four layers: 252 | 253 | 1. If `highway=='trunk'` & `motor_vehicle=='no'`, draw `line_color: :green` and `line_width 2` 254 | 2. If `highway=='primary'` & `motor_vehicle=='no'`, draw `line_color: :blue` and `line_width 2` 255 | 3. If `highway=='trunk'` & `motor_vehicle!='no'`, draw `line_color: :green` and `line_width 4` 256 | 4. If `highway=='primary'` & `motor_vehicle!='no'`, draw `line_color: :blue` and `line_width 4` 257 | 258 | The `cascade` instruction applies to sublayers created below it, but not to those created above, or to the parent layer. Cascades do not multiply each other, so if you write 259 | 260 | ```ruby 261 | cascade(motor_vehicle=='no') { line_width 2 } 262 | cascade(route=='bus') { line_color :red } 263 | ``` 264 | 265 | this will not create rules for a combined `motor_vehicle=='no' & route=='bus'` condition - you must do that yourself. 266 | 267 | Each cascade doubles the number of sublayer rules, so use them with great care! 268 | 269 | ### Still Ruby 270 | 271 | Despite these additions, Glug is Ruby at heart so you can use comments, variables, includes, multi-statement lines, do/end blocks, all as you'd expect. Don't expect error-trapping to quite be the same - since Glug interprets unknown words as tag keys, errors can sometimes be swallowed up. 272 | 273 | You can even use Ruby's lambdas to set a value as a fraction of the previously set one: 274 | 275 | ```ruby 276 | line_width 4 277 | on(urban==true) { line_width ->(old_value){ old_value/2.0 } } 278 | ``` 279 | 280 | ## To do 281 | 282 | * Glug is in alpha. Things may break. 283 | * Glug doesn't yet support class-specific paint properties. 284 | * Glug doesn't yet do anything clever with sprite or glyph directives, but maybe it should. 285 | 286 | ## Contributing 287 | 288 | Bug reports, suggestions and (especially!) pull requests are very welcome on the Github issue tracker. Please check the tracker to see if your issue is already known, and be nice. For questions, please use IRC (irc.oftc.net or http://irc.osm.org, channel #osm-dev) and http://help.osm.org. 289 | 290 | Formatting: braces and indents as shown, hard tabs (4sp). (Yes, I know.) Please be conservative about adding dependencies. 291 | 292 | ## Copyright and contact 293 | 294 | Richard Fairhurst, 2022. This code is licensed as FTWPL; you may do anything you like with this code and there is no warranty. 295 | 296 | If you'd like to sponsor development of Glug, you can contact me at richard@systemeD.net. 297 | 298 | Check out [tilemaker](https://github.com/systemed/tilemaker) to produce the vector tiles which MapLibre and Mapbox GL consume. 299 | -------------------------------------------------------------------------------- /bin/glug: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'glug' 4 | 5 | puts Glug::Stylesheet.new { instance_eval(File.read(ARGV[0])) }.to_json 6 | -------------------------------------------------------------------------------- /glug.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'glug' 3 | s.version = '0.0.8' 4 | s.date = '2022-11-23' 5 | s.summary = "Glug" 6 | s.description = "Text-based markup for Mapbox GL styles" 7 | s.authors = ["Richard Fairhurst"] 8 | s.email = 'richard@systemeD.net' 9 | s.files = ["lib/glug.rb", "lib/glug/condition.rb", "lib/glug/extensions.rb", "lib/glug/layer.rb", "lib/glug/stylesheet.rb"] 10 | s.homepage = 'http://github.com/systemed/glug' 11 | s.license = 'FTWPL' 12 | s.add_dependency 'neatjson' 13 | s.add_dependency 'chroma' 14 | s.add_dependency 'hsluv' 15 | s.executables << 'glug' 16 | end 17 | -------------------------------------------------------------------------------- /lib/glug.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'neatjson' 3 | 4 | module Glug 5 | end 6 | 7 | require_relative 'glug/condition' 8 | require_relative 'glug/stylesheet' 9 | require_relative 'glug/layer' 10 | require_relative 'glug/extensions' 11 | -------------------------------------------------------------------------------- /lib/glug/condition.rb: -------------------------------------------------------------------------------- 1 | module Glug # :nodoc: 2 | 3 | # ----- Subscriptable 4 | # allows us to create conditions with syntax 5 | # any[(highway=='primary'),(highway=='trunk')] 6 | 7 | class Subscriptable 8 | def initialize(type) 9 | @type=type 10 | end 11 | def [](*arguments) 12 | Condition.new.from_list(@type, arguments) 13 | end 14 | end 15 | 16 | # ----- Condition 17 | # represents a GL filter of the form [operator, key, value] (etc.) 18 | # can be merged with other conditions via the & and | operators 19 | 20 | class Condition 21 | attr_accessor :values, :operator 22 | 23 | # GL operators we can't use verbatim (mostly Ruby reserved words) 24 | SUBSTITUTIONS = { 25 | string_format: "format", 26 | is_in: "in", 27 | case_when: "case", 28 | _!: "!", 29 | subtract: "-", divide: "/", pow: "^", # so we can write 'subtract(100,height)' 30 | feature_id: "id" # Glug already uses 'id' 31 | } 32 | 33 | # GL operators that make sense to use as suffixed dot methods 34 | DOT_METHODS = [ 35 | :array, :boolean, :string_format, :image, :number, :number_format, :object, :string, 36 | :to_boolean, :to_color, :to_number, :to_string, :typeof, 37 | :length, :slice, :match, 38 | :downcase, :upcase, :is_supported_script, :to_rgba, 39 | :abs, :acos, :asin, :atan, :ceil, :cos, :floor, :ln, :log10, :log2, :round, :sin, :sqrt, :tan 40 | ] 41 | 42 | def is(*args); Condition.new.from_key(:==, self, args) end 43 | def ==(*args); Condition.new.from_key(:==, self, args) end 44 | def !=(*args); Condition.new.from_key(:!=, self, args) end 45 | def <(*args); Condition.new.from_key(:< , self, args) end 46 | def >(*args); Condition.new.from_key(:> , self, args) end 47 | def <=(*args); Condition.new.from_key(:<=, self, args) end 48 | def >=(*args); Condition.new.from_key(:>=, self, args) end 49 | def %(*args); Condition.new.from_key(:% , self, args) end 50 | def +(*args); Condition.new.from_key(:+ , self, args) end 51 | def -(*args); Condition.new.from_key(:- , self, args) end 52 | def *(*args); Condition.new.from_key(:* , self, args) end 53 | def /(*args); Condition.new.from_key(:/ , self, args) end 54 | def **(*args); Condition.new.from_key(:^ , self, args) end 55 | def in(*args); Condition.new.from_key(:in, self, [[:literal,args.flatten]]) end 56 | def [](*args); Condition.new.from_key(:at, args[0], [self]) end 57 | def coerce(other); [Condition.new.just_value(other), self] end 58 | 59 | def initialize 60 | @values=[] 61 | end 62 | def from_key(operator, key, list) 63 | @operator = SUBSTITUTIONS[operator] || operator.to_s.gsub('_','-') 64 | @values = [key].concat(list) 65 | self 66 | end 67 | def from_list(operator, list) 68 | @operator = SUBSTITUTIONS[operator] || operator.to_s.gsub('_','-') 69 | @values = list 70 | self 71 | end 72 | def just_value(val) 73 | @operator = nil 74 | @values = [val] 75 | self 76 | end 77 | 78 | def &(cond); merge(:all,cond) end 79 | def |(cond); merge(:any,cond) end 80 | def merge(op,cond) 81 | if cond.nil? 82 | self 83 | elsif @operator==op 84 | Condition.new.from_list(op, @values + [cond]) 85 | elsif cond.operator==op 86 | Condition.new.from_list(op, [self] + cond.values) 87 | else 88 | Condition.new.from_list(op, [self, cond]) 89 | end 90 | end 91 | def <<(cond); @values << cond.encode; self end 92 | 93 | # Support dot access for most methods 94 | def method_missing(method_sym, *args) 95 | if DOT_METHODS.include?(method_sym) 96 | Condition.new.from_key(method_sym, self, args) 97 | else 98 | super 99 | end 100 | end 101 | 102 | # Encode into an array for GL JSON (recursive) 103 | def encode 104 | transform_underscores 105 | values = @values.map { |v| v.is_a?(Condition) ? v.encode : v } 106 | @operator.nil? ? values[0] : [@operator.to_s, *values] 107 | end 108 | def to_json(opts) 109 | encode.to_json(opts) 110 | end 111 | def to_s 112 | "" 113 | end 114 | 115 | # Transform nested { font_scale: 0.8 } to { "font-scale"=>0.8 } 116 | def transform_underscores 117 | @values.map! do |v| 118 | if v.is_a?(Hash) 119 | new_hash = {} 120 | v.each { |hk,hv| new_hash[hk.is_a?(Symbol) ? hk.to_s.gsub('_','-') : hk] = hv } 121 | new_hash 122 | else 123 | v 124 | end 125 | end 126 | end 127 | 128 | end # class Condition 129 | end # module Glug 130 | -------------------------------------------------------------------------------- /lib/glug/extensions.rb: -------------------------------------------------------------------------------- 1 | require 'chroma' 2 | require 'hsluv' 3 | 4 | # Colour methods on Integer 5 | 6 | class Integer 7 | def chroma_hex(op,p) 8 | ("#"+to_s(16).rjust(6,'0')).paint.send(op,p).to_hex 9 | end 10 | def chroma(op,p) 11 | chroma_hex(op,p).gsub('#','0x').to_i(16) 12 | end 13 | def to_hex_color 14 | '#' + to_s(16).rjust(6,'0') 15 | end 16 | end 17 | 18 | # Top-level colour generators 19 | 20 | def hsluv(h,s,l) 21 | arr = Hsluv.rgb_prepare(Hsluv.hsluv_to_rgb(h,s*100,l*100)) 22 | (arr[0]*256 + arr[1])*256 + arr[2] 23 | end 24 | def hsl(h,s,l) 25 | rgb = Chroma::RgbGenerator::FromHslValues.new('hex6',h,s,l).generate[0] 26 | ((rgb.r).to_i*256 + (rgb.g).to_i)*256 + rgb.b.to_i 27 | end 28 | -------------------------------------------------------------------------------- /lib/glug/layer.rb: -------------------------------------------------------------------------------- 1 | module Glug # :nodoc: 2 | 3 | # ----- Layer 4 | # a layer in a GL style 5 | # this is where most of the hard work happens, including 'method_missing' and 'on' calls to provide the grammar 6 | 7 | class Layer 8 | 9 | # GL properties (as distinct from OSM keys) 10 | LAYOUT = [ :visibility, 11 | :line_cap, :line_join, :line_miter_limit, :line_round_limit, 12 | :symbol_placement, :symbol_spacing, :symbol_avoid_edges, :symbol_z_order, 13 | :icon_allow_overlap, :icon_ignore_placement, :icon_optional, :icon_rotation_alignment, :icon_size, 14 | :icon_image, :icon_rotate, :icon_padding, :icon_keep_upright, :icon_offset, 15 | :icon_text_fit, :icon_text_fit_padding, :icon_anchor, :icon_pitch_alignment, 16 | :text_rotation_alignment, :text_field, :text_font, :text_size, :text_max_width, :text_line_height, 17 | :text_letter_spacing, :text_justify, :text_anchor, :text_max_angle, :text_rotate, :text_padding, 18 | :text_keep_upright, :text_transform, :text_offset, :text_allow_overlap, :text_ignore_placement, :text_optional, 19 | :text_pitch_alignment ] 20 | PAINT = [ :background_color, :background_pattern, :background_opacity, 21 | :fill_antialias, :fill_opacity, :fill_color, :fill_outline_color, :fill_translate, :fill_translate_anchor, :fill_pattern, 22 | :line_opacity, :line_color, :line_translate, :line_translate_anchor, :line_width, :line_gap_width, :line_offset, 23 | :line_blur, :line_dasharray, :line_pattern, :line_gradient, 24 | :icon_opacity, :icon_color, :icon_halo_color, :icon_halo_width, :icon_halo_blur, :icon_translate, :icon_translate_anchor, 25 | :text_opacity, :text_color, :text_halo_color, :text_halo_width, :text_halo_blur, :text_translate, :text_translate_anchor, 26 | :raster_opacity, :raster_hue_rotate, :raster_brightness_min, :raster_brightness_max, :raster_saturation, :raster_contrast, :raster_resampling, :raster_fade_duration, 27 | :circle_radius, :circle_color, :circle_blur, :circle_opacity, :circle_translate, :circle_translate_anchor, 28 | :circle_pitch_scale, :circle_pitch_alignment, :circle_stroke_width, :circle_stroke_color, :circle_stroke_opacity, 29 | :fill_extrusion_opacity, :fill_extrusion_color, :fill_extrusion_translate, :fill_extrusion_translate_anchor, 30 | :fill_extrusion_pattern, :fill_extrusion_height, :fill_extrusion_base, :fill_extrusion_vertical_gradient, 31 | :heatmap_radius, :heatmap_weight, :heatmap_intensity, :heatmap_color, :heatmap_opacity, 32 | :hillshade_illumination_direction, :hillshade_illumination_anchor, :hillshade_exaggeration, 33 | :hillshade_shadow_color, :hillshade_highlight_color, :hillshade_accent_color ] 34 | TOP_LEVEL = [ :metadata, :zoom, :interactive ] 35 | HIDDEN = [ :ref, :source, :source_layer, :id, :type, :filter, :layout, :paint ] # top level, not settable by commands 36 | EXPRESSIONS=[ :array, :boolean, :collator, :string_format, :image, :literal, :number, 37 | :number_format, :object, :string, :to_boolean, :to_color, :to_number, :to_string, 38 | :typeof, :accumulated, :feature_state, :geometry_type, :feature_id, 39 | :line_progress, :properties, :at, :get, :has, :is_in, :index_of, 40 | :length, :slice, 41 | :all, :any, :case_when, :coalesce, :match, :within, 42 | :interpolate, :interpolate_hcl, :interpolate_lab, :step, 43 | :let, :var, :concat, :downcase, :upcase, 44 | :is_supported_script, :resolved_locale, 45 | :rgb, :rgba, :to_rgba, :abs, :acos, :asin, :atan, :ceil, :cos, :distance, 46 | :e, :floor, :ln, :ln2, :log10, :log2, :max, :min, :pi, :round, :sin, :sqrt, :tan, 47 | :distance_from_center, :pitch, :zoom, :heatmap_density, 48 | :subtract, :divide, :pow, :_! ] 49 | 50 | # Shared properties that can be recalled by using a 'ref' 51 | REF_PROPERTIES = ['type', 'source', 'source-layer', 'minzoom', 'maxzoom', 'filter', 'layout'] 52 | 53 | attr_accessor :kv # key-value pairs for layout, paint, and top level 54 | attr_accessor :condition # filter condition 55 | attr_accessor :stylesheet # parent stylesheet object 56 | 57 | def initialize(stylesheet, args={}) 58 | @stylesheet = stylesheet 59 | @condition = args[:condition] 60 | @kv = args[:kv] || {} 61 | @kv[:id] = args[:id] 62 | if args[:zoom] then @kv[:zoom]=args[:zoom] end 63 | 64 | @type = nil # auto-detected layer type 65 | @write = true # write this layer out, or has it been suppressed? 66 | @cascade_cond = nil # are we currently evaluating a cascade directive? 67 | @cascades = args[:cascades] || [] # cascade list to apply to all subsequent layers 68 | @uncascaded = nil # condition to add to non-cascaded layers 69 | 70 | @kv[:source] ||= stylesheet.sources.find {|k,v| v[:default] }[0] 71 | @kv[:source_layer] ||= args[:id] if stylesheet.sources[@kv[:source]][:type]=="vector" 72 | @child_num = 0 # incremented sublayer suffix 73 | end 74 | 75 | # Handle all missing 'method' calls 76 | # If we can match it to a GL property, it's an assignment: 77 | # otherwise it's an OSM key 78 | def method_missing(method_sym, *arguments) 79 | if EXPRESSIONS.include?(method_sym) 80 | return Condition.new.from_list(method_sym, arguments) 81 | elsif LAYOUT.include?(method_sym) || PAINT.include?(method_sym) || TOP_LEVEL.include?(method_sym) 82 | v = arguments.length==1 ? arguments[0] : arguments 83 | if v.is_a?(Proc) then v=v.call(@kv[method_sym]) end 84 | if @cascade_cond.nil? 85 | @kv[method_sym] = v 86 | else 87 | _add_cascade_condition(method_sym, v) 88 | end 89 | else 90 | return Condition.new.from_list("get", [method_sym]) 91 | end 92 | end 93 | 94 | # Convenience so we can write literal(1,2,3) rather than literal([1,2,3]) 95 | def literal(*args) 96 | if args.length==1 && args[0].is_a?(Hash) 97 | # Hashes - literal(frog: 1, bill: 2) 98 | Condition.new.from_list(:literal, [args[0]]) 99 | else 100 | # Arrays - literal(1,2,3) 101 | Condition.new.from_list(:literal, [args]) 102 | end 103 | end 104 | 105 | # Return a current value from @kv 106 | # This allows us to do: line_width current_value(:line_width)/2.0 107 | def current_value(key) 108 | @kv[key] 109 | end 110 | 111 | # Add a sublayer with an additional filter 112 | def on(*args, &block) 113 | @child_num+=1 114 | r = Layer.new(@stylesheet, 115 | :id => "#{@kv[:id]}__#{@child_num}".to_sym, 116 | :kv => @kv.dup, :cascades => @cascades.dup) 117 | 118 | # Set zoom level 119 | if args[0].is_a?(Range) || args[0].is_a?(Integer) 120 | r.kv[:zoom] = args.shift 121 | end 122 | 123 | # Set condition 124 | sub_cond = nil 125 | if args.empty? 126 | sub_cond = @condition # just inherit parent layer's condition 127 | else 128 | sub_cond = (args.length==1) ? args[0] : Condition.new.from_list(:any,args) 129 | sub_cond = nilsafe_merge(sub_cond, @condition) 130 | end 131 | r._set_filter(nilsafe_merge(sub_cond, @uncascaded)) 132 | r.instance_eval(&block) 133 | @stylesheet._add_layer(r) 134 | 135 | # Create cascaded layers 136 | child_chr='a' 137 | @cascades.each do |c| 138 | c_cond, c_kv = c 139 | l = Layer.new(@stylesheet, :id=>"#{r.kv[:id]}__#{child_chr}", :kv=>r.kv.dup) 140 | l._set_filter(nilsafe_merge(sub_cond, c_cond)) 141 | l.kv.merge!(c_kv) 142 | @stylesheet._add_layer(l) 143 | child_chr.next! 144 | end 145 | end 146 | 147 | # Nil-safe merge 148 | def nilsafe_merge(a,b) 149 | a.nil? ? b : (a & b) 150 | end 151 | 152 | # Add a cascading condition 153 | def cascade(*args, &block) 154 | cond = (args.length==1) ? args[0] : Condition.new.from_list(:any,args) 155 | @cascade_cond = cond 156 | self.instance_eval(&block) 157 | @cascade_cond = nil 158 | end 159 | def _add_cascade_condition(k, v) 160 | if @cascades.length>0 && @cascades[-1][0].to_s==@cascade_cond.to_s 161 | @cascades[-1][1][k]=v 162 | else 163 | @cascades << [@cascade_cond, { k=>v }] 164 | end 165 | end 166 | def uncascaded(*args) 167 | cond = case args.length 168 | when 0; nil 169 | when 1; args[0] 170 | else; Condition.new.from_list(:any,args) 171 | end 172 | @uncascaded = cond 173 | end 174 | 175 | # Setters for @condition (making sure we copy when inheriting) 176 | def filter(*args) 177 | _set_filter(args.length==1 ? args[0] : Condition.new.from_list(:any,args)) 178 | end 179 | def _set_filter(condition) 180 | @condition = condition.nil? ? nil : condition.dup 181 | end 182 | 183 | # Set layer name 184 | def id(name) 185 | @kv[:id] = name 186 | end 187 | 188 | # Suppress output of this layer 189 | def suppress; @write = false end 190 | def write?; @write end 191 | 192 | # Square-bracket filters (any[...], all[...]) 193 | def any ; return Subscriptable.new(:any ) end 194 | def all ; return Subscriptable.new(:all ) end 195 | 196 | # Deduce 'type' attribute from style attributes 197 | def set_type_from(s) 198 | return unless s.include?('-') 199 | t = (s=~/^fill-extrusion/ ? "fill-extrusion" : s.split('-')[0]).to_sym 200 | if t==:icon || t==:text then t=:symbol end 201 | if @type && @type!=t then raise "Attribute #{s} conflicts with deduced type #{@type} in layer #{@kv[:id]}" end 202 | @type=t 203 | end 204 | 205 | # Create a GL-format hash from a layer definition 206 | def to_hash 207 | hash = { :layout=> {}, :paint => {} } 208 | 209 | # Assign key/values to correct place 210 | @kv.each do |k,v| 211 | s = k.to_s.gsub('_','-') 212 | if s.include?('-color') && v.is_a?(Integer) then v = "#%06x" % v end 213 | if v.respond_to?(:encode) then v=v.encode end 214 | 215 | if LAYOUT.include?(k) 216 | hash[:layout][s]=v 217 | set_type_from s 218 | elsif PAINT.include?(k) 219 | hash[:paint][s]=v 220 | set_type_from s 221 | elsif TOP_LEVEL.include?(k) || HIDDEN.include?(k) 222 | hash[s]=v 223 | else raise "#{s} isn't a recognised layer attribute" 224 | end 225 | end 226 | 227 | hash['type'] = @type 228 | if @condition then hash['filter'] = @condition.encode end 229 | 230 | # Convert zoom level 231 | if (v=hash['zoom']) 232 | hash['minzoom'] = v.is_a?(Range) ? v.first : v 233 | hash['maxzoom'] = v.is_a?(Range) ? v.last : v 234 | hash.delete('zoom') 235 | end 236 | 237 | # See if we can reuse an earlier layer's properties 238 | mk = ref_key(hash) 239 | if stylesheet.refs[mk] 240 | REF_PROPERTIES.each { |k| hash.delete(k) } 241 | hash['ref'] = stylesheet.refs[mk] 242 | else 243 | stylesheet.refs[mk] = hash['id'] 244 | end 245 | 246 | if hash[:layout].empty? && hash[:paint].empty? 247 | nil 248 | else 249 | hash.delete(:layout) if hash[:layout].empty? 250 | hash.delete(:paint) if hash[:paint].empty? 251 | hash 252 | end 253 | end 254 | 255 | # Key to identify matching layer properties (slow but...) 256 | def ref_key(hash) 257 | (REF_PROPERTIES.collect { |k| hash[k] } ).to_json 258 | end 259 | 260 | end # class Layer 261 | end # module Glug 262 | -------------------------------------------------------------------------------- /lib/glug/stylesheet.rb: -------------------------------------------------------------------------------- 1 | module Glug # :nodoc: 2 | 3 | # ----- Stylesheet 4 | # the main document object 5 | 6 | class Stylesheet 7 | attr_accessor :sources, :kv, :refs, :base_dir, :params 8 | 9 | def initialize(base_dir: nil, params: nil, &block) 10 | @sources = {} 11 | @kv = {} 12 | @layers = [] 13 | @refs = {} 14 | @base_dir = base_dir || '' 15 | @params = params || {} 16 | instance_eval(&block) 17 | end 18 | 19 | # Set a property, e.g. 'bearing 29' 20 | def method_missing(method_sym, *arguments) 21 | @kv[method_sym] = arguments[0] 22 | end 23 | 24 | # Add a source 25 | def source(source_name, opts={}) 26 | @sources[source_name] = opts 27 | end 28 | 29 | # Add a layer 30 | # creates a new Layer object using the block supplied 31 | def layer(id, opts={}, &block) 32 | r = Layer.new(self, :id=>id, :kv=>opts) 33 | @layers << r 34 | r.instance_eval(&block) 35 | end 36 | 37 | # Assemble into GL JSON format 38 | def to_hash 39 | out = @kv.dup 40 | out['sources'] = @sources.dup 41 | out['sources'].each { |k,v| v.delete(:default); out['sources'][k] = v } 42 | out['layers'] = @layers.select { |r| r.write? }.collect { |r| r.to_hash }.compact 43 | out 44 | end 45 | def to_json(*args); JSON.neat_generate(to_hash) end 46 | 47 | # Setter for Layer to add sublayers 48 | def _add_layer(layer) 49 | @layers << layer 50 | end 51 | 52 | # Load file 53 | def include_file(fn) 54 | instance_eval(File.read(File.join(@base_dir, fn))) 55 | end 56 | end 57 | end 58 | --------------------------------------------------------------------------------