libgd-gis 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/gd/gis/map.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "basemap"
2
4
  require_relative "projection"
3
5
  require_relative "classifier"
@@ -6,17 +8,58 @@ require_relative "layer_points"
6
8
  require_relative "layer_lines"
7
9
  require_relative "layer_polygons"
8
10
 
11
+ LINE_GEOMS = %w[LineString MultiLineString].freeze
12
+ POLY_GEOMS = %w[Polygon MultiPolygon].freeze
13
+
9
14
  module GD
10
15
  module GIS
11
- attr_accessor :debug
12
-
16
+ # Represents a complete renderable map.
17
+ #
18
+ # A Map is responsible for:
19
+ # - Managing the geographic extent (bounding box + zoom)
20
+ # - Fetching and compositing basemap tiles
21
+ # - Managing semantic feature layers
22
+ # - Managing generic overlay layers (points, lines, polygons)
23
+ # - Executing the rendering pipeline
24
+ #
25
+ # The map can render either:
26
+ # - A full tile-based image, or
27
+ # - A fixed-size viewport clipped from the basemap
28
+ #
13
29
  class Map
30
+ # Tile size in pixels (Web Mercator standard)
14
31
  TILE_SIZE = 256
15
32
 
33
+ # @return [GD::Image, nil] rendered image
16
34
  attr_reader :image
35
+
36
+ # @return [Hash<Symbol, Array>] semantic feature layers
17
37
  attr_reader :layers
38
+
39
+ # @return [Object, nil] style object
18
40
  attr_accessor :style
19
41
 
42
+ # @return [Boolean] enables debug rendering
43
+ attr_reader :debug
44
+
45
+ # Creates a new map.
46
+ #
47
+ # @param bbox [Array<Float>]
48
+ # bounding box [min_lng, min_lat, max_lng, max_lat]
49
+ # @param zoom [Integer]
50
+ # zoom level
51
+ # @param basemap [Symbol]
52
+ # basemap provider identifier
53
+ # @param width [Integer, nil]
54
+ # viewport width in pixels
55
+ # @param height [Integer, nil]
56
+ # viewport height in pixels
57
+ # @param crs [String, Symbol, nil]
58
+ # input CRS identifier
59
+ # @param fitted_bbox [Boolean]
60
+ # whether the provided bbox is already viewport-fitted
61
+ #
62
+ # @raise [ArgumentError] if parameters are invalid
20
63
  def initialize(
21
64
  bbox:,
22
65
  zoom:,
@@ -26,25 +69,19 @@ module GD
26
69
  crs: nil,
27
70
  fitted_bbox: false
28
71
  )
29
- # --------------------------------------------------
30
72
  # 1. Basic input validation
31
- # --------------------------------------------------
32
73
  raise ArgumentError, "bbox must be [min_lng, min_lat, max_lng, max_lat]" unless
33
74
  bbox.is_a?(Array) && bbox.size == 4
34
75
 
35
76
  raise ArgumentError, "zoom must be an Integer" unless zoom.is_a?(Integer)
36
77
 
37
- if (width && !height) || (!width && height)
38
- raise ArgumentError, "width and height must be provided together"
39
- end
78
+ raise ArgumentError, "width and height must be provided together" if (width && !height) || (!width && height)
40
79
 
41
80
  @zoom = zoom
42
81
  @width = width
43
82
  @height = height
44
83
 
45
- # --------------------------------------------------
46
84
  # 2. CRS normalization (input → WGS84 lon/lat)
47
- # --------------------------------------------------
48
85
  if crs
49
86
  normalizer = GD::GIS::CRS::Normalizer.new(crs)
50
87
 
@@ -54,9 +91,7 @@ module GD
54
91
  bbox = [min_lng, min_lat, max_lng, max_lat]
55
92
  end
56
93
 
57
- # --------------------------------------------------
58
94
  # 3. Final bbox (viewport-aware if width/height)
59
- # --------------------------------------------------
60
95
  @bbox =
61
96
  if width && height && !fitted_bbox
62
97
  GD::GIS::Geometry.viewport_bbox(
@@ -69,14 +104,10 @@ module GD
69
104
  bbox
70
105
  end
71
106
 
72
- # --------------------------------------------------
73
107
  # 4. Basemap (uses FINAL bbox)
74
- # --------------------------------------------------
75
108
  @basemap = GD::GIS::Basemap.new(zoom, @bbox, basemap)
76
109
 
77
- # --------------------------------------------------
78
110
  # 5. Legacy semantic layers (REQUIRED by render)
79
- # --------------------------------------------------
80
111
  @layers = {
81
112
  motorway: [],
82
113
  primary: [],
@@ -91,21 +122,22 @@ module GD
91
122
  # Optional alias (semantic clarity, no behavior change)
92
123
  @road_layers = @layers
93
124
 
94
- # --------------------------------------------------
95
125
  # 6. Overlay layers (generic)
96
- # --------------------------------------------------
97
126
  @points_layers = []
98
127
  @lines_layers = []
99
128
  @polygons_layers = []
100
129
 
101
- # --------------------------------------------------
102
130
  # 7. Style
103
- # --------------------------------------------------
104
131
  @style = nil
105
132
 
106
- @debug = false
133
+ @debug = false
134
+ @used_labels = {}
107
135
  end
108
136
 
137
+ # Returns all features belonging to a given semantic layer.
138
+ #
139
+ # @param layer [Symbol]
140
+ # @return [Array<Feature>]
109
141
  def features_by_layer(layer)
110
142
  return [] unless @layers[layer]
111
143
 
@@ -114,25 +146,68 @@ module GD
114
146
  end
115
147
  end
116
148
 
149
+ # Returns all features in the map.
150
+ #
151
+ # @return [Array<Feature>]
117
152
  def features
118
153
  @layers.values.flatten.map do |item|
119
154
  item.is_a?(Array) ? item.last : item
120
155
  end
121
156
  end
122
157
 
123
- # -----------------------------------
124
- # GeoJSON input (unchanged behavior)
125
- # -----------------------------------
158
+ def maybe_create_line_label(feature)
159
+ geom = feature.geometry
160
+ return unless LINE_GEOMS.include?(geom["type"])
161
+
162
+ name = feature.properties["name"]
163
+ return if name.nil? || name.empty?
164
+
165
+ key = feature.properties["wikidata"] || name
166
+ return if @used_labels[key]
167
+
168
+ coords = geom["coordinates"]
169
+ coords = coords.flatten(1) if geom["type"] == "MultiLineString"
170
+ return if coords.size < 2
171
+
172
+ lon, lat = coords[coords.size / 2]
173
+
174
+ puts @style
175
+
176
+ @points_layers << GD::GIS::PointsLayer.new(
177
+ [feature],
178
+ lon: ->(_) { lon },
179
+ lat: ->(_) { lat },
180
+ icon: nil,
181
+ label: ->(_) { name },
182
+ font: GD::GIS::FontHelper.random,
183
+ size: 10,
184
+ color: GD::Color.rgb(0, 0, 0)
185
+ )
186
+
187
+ @used_labels[key] = true
188
+ end
189
+
190
+ # Loads features from a GeoJSON file.
191
+ #
192
+ # This method:
193
+ # - Normalizes CRS
194
+ # - Classifies features into semantic layers
195
+ # - Creates overlay layers when needed (points)
196
+ #
197
+ # @param path [String] path to GeoJSON file
198
+ # @return [void]
126
199
  def add_geojson(path)
127
200
  features = LayerGeoJSON.load(path)
128
201
 
129
202
  features.each do |feature|
203
+ maybe_create_line_label(feature)
204
+
130
205
  case feature.layer
131
206
  when :water
132
207
  kind =
133
208
  case (feature.properties["objeto"] || feature.properties["waterway"]).to_s.downcase
134
- when /river|río/ then :river
135
- when /stream|arroyo/ then :stream
209
+ when /river|río|canal/ then :river
210
+ when /stream|arroyo/ then :stream
136
211
  else :minor
137
212
  end
138
213
 
@@ -146,25 +221,33 @@ module GD
146
221
 
147
222
  when :track
148
223
  # elegí una:
149
- @layers[:minor] << feature
224
+ @layers[:minor] << feature
150
225
  # o @layers[:street] << feature
151
226
  else
152
227
  geom_type = feature.geometry["type"]
153
228
 
154
229
  if geom_type == "Point"
155
- points_style = @style.points or
156
- raise ArgumentError, "Style error: missing 'points' section"
230
+ points_style = @style.points || begin
231
+ warn "Style error: missing 'points' section"
232
+ end
157
233
 
158
- font = points_style[:font] or
159
- raise ArgumentError, "Style error: points.font is required"
234
+ font = points_style[:font] || begin
235
+ warn "[libgd-gis] points.font not defined in style, using random system font"
236
+ GD::GIS::FontHelper.random
237
+ end
160
238
 
161
- size = points_style[:size] or
162
- raise ArgumentError, "Style error: points.size is required"
239
+ size = points_style[:size] || begin
240
+ warn "[libgd-gis] points.font size not defined in style, using random system font size"
241
+ (6..14).to_a.sample
242
+ end
163
243
 
164
244
  raw_color = points_style[:color]
165
245
  color = @style.normalize_color(raw_color)
166
246
 
167
- icon = points_style.key?(:icon_fill) && points_style.key?(:icon_stroke) ? [points_style[:icon_stroke], points_style[:icon_stroke]] : nil
247
+ icon = if points_style.key?(:icon_fill) && points_style.key?(:icon_stroke)
248
+ [points_style[:icon_stroke],
249
+ points_style[:icon_stroke]]
250
+ end
168
251
  icon = points_style.key?(:icon) ? points_style[:icon] : nil if icon.nil?
169
252
 
170
253
  @points_layers << GD::GIS::PointsLayer.new(
@@ -172,36 +255,52 @@ module GD
172
255
  lon: ->(f) { f.geometry["coordinates"][0] },
173
256
  lat: ->(f) { f.geometry["coordinates"][1] },
174
257
  icon: icon,
175
- label: ->(f) { f.properties["name"] }, # 👈 TEXTO
258
+ label: ->(f) { f.properties["name"] },
176
259
  font: font,
177
260
  size: size,
178
261
  color: color
179
262
  )
180
- elsif geom_type == "LineString" || geom_type == "MultiLineString"
263
+ elsif LINE_GEOMS.include?(geom_type)
181
264
  @layers[:minor] << feature
182
265
  end
183
266
  end
184
267
  end
185
268
  end
186
269
 
187
- # -----------------------------------
188
- # Overlay layers
189
- # -----------------------------------
270
+ # Adds a generic points overlay layer.
271
+ #
272
+ # @param data [Enumerable]
273
+ # @param opts [Hash]
274
+ # @return [void]
190
275
  def add_points(data, **opts)
191
276
  @points_layers << GD::GIS::PointsLayer.new(data, **opts)
192
277
  end
193
278
 
279
+ # Adds a generic lines overlay layer.
280
+ #
281
+ # @param features [Array]
282
+ # @param opts [Hash]
283
+ # @return [void]
194
284
  def add_lines(features, **opts)
195
285
  @lines_layers << GD::GIS::LinesLayer.new(features, **opts)
196
286
  end
197
287
 
288
+ # Adds a generic polygons overlay layer.
289
+ #
290
+ # @param polygons [Array]
291
+ # @param opts [Hash]
292
+ # @return [void]
198
293
  def add_polygons(polygons, **opts)
199
294
  @polygons_layers << GD::GIS::PolygonsLayer.new(polygons, **opts)
200
295
  end
201
296
 
202
- # -----------------------------------
203
- # Rendering (LEGACY, UNCHANGED)
204
- # -----------------------------------
297
+ # Renders the map.
298
+ #
299
+ # Chooses between tile rendering and viewport rendering
300
+ # depending on whether width and height are set.
301
+ #
302
+ # @return [void]
303
+ # @raise [RuntimeError] if style is not set
205
304
  def render
206
305
  raise "map.style must be set" unless @style
207
306
 
@@ -211,7 +310,7 @@ module GD
211
310
  render_tiles
212
311
  end
213
312
  end
214
-
313
+
215
314
  def render_tiles
216
315
  raise "map.style must be set" unless @style
217
316
 
@@ -248,12 +347,12 @@ module GD
248
347
  [(x - origin_x).round, (y - origin_y).round]
249
348
  end
250
349
 
251
- # 1️⃣ GeoJSON semantic layers
350
+ # 1. GeoJSON semantic layers
252
351
  @style.order.each do |kind|
253
352
  draw_layer(kind, projection)
254
353
  end
255
354
 
256
- # 2️⃣ Generic overlays
355
+ # 2. Generic overlays
257
356
  @polygons_layers.each { |l| l.render!(@image, projection) }
258
357
  @lines_layers.each { |l| l.render!(@image, projection) }
259
358
  @points_layers.each { |l| l.render!(@image, projection) }
@@ -265,22 +364,16 @@ module GD
265
364
  @image = GD::Image.new(@width, @height)
266
365
  @image.antialias = false
267
366
 
268
- # --------------------------------------------------
269
367
  # 1. Compute global pixel bbox
270
- # --------------------------------------------------
271
368
  min_lng, min_lat, max_lng, max_lat = @bbox
272
369
 
273
370
  x1, y1 = GD::GIS::Projection.lonlat_to_global_px(min_lng, max_lat, @zoom)
274
- x2, y2 = GD::GIS::Projection.lonlat_to_global_px(max_lng, min_lat, @zoom)
371
+ GD::GIS::Projection.lonlat_to_global_px(max_lng, min_lat, @zoom)
275
372
 
276
- # --------------------------------------------------
277
373
  # 2. Fetch tiles
278
- # --------------------------------------------------
279
374
  tiles, = @basemap.fetch_tiles
280
375
 
281
- # --------------------------------------------------
282
376
  # 3. Draw tiles clipped to viewport
283
- # --------------------------------------------------
284
377
  tiles.each do |x, y, file|
285
378
  tile = GD::Image.open(file)
286
379
 
@@ -309,16 +402,12 @@ module GD
309
402
  )
310
403
  end
311
404
 
312
- # --------------------------------------------------
313
405
  # 4. Projection (viewport version)
314
- # --------------------------------------------------
315
406
  projection = lambda do |lon, lat|
316
407
  GD::GIS::Geometry.project(lon, lat, @bbox, @zoom)
317
408
  end
318
409
 
319
- # --------------------------------------------------
320
410
  # 5. REUSE the same render pipeline
321
- # --------------------------------------------------
322
411
  @style.order.each do |kind|
323
412
  draw_layer(kind, projection)
324
413
  end
@@ -328,10 +417,19 @@ module GD
328
417
  @points_layers.each { |l| l.render!(@image, projection) }
329
418
  end
330
419
 
420
+ # Saves the rendered image to disk.
421
+ #
422
+ # @param path [String]
423
+ # @return [void]
331
424
  def save(path)
332
425
  @image.save(path)
333
426
  end
334
427
 
428
+ # Draws a semantic feature layer.
429
+ #
430
+ # @param kind [Symbol]
431
+ # @param projection [#call]
432
+ # @return [void]
335
433
  def draw_layer(kind, projection)
336
434
  items = @layers[kind]
337
435
  return if items.nil? || items.empty?
@@ -374,24 +472,20 @@ module GD
374
472
  f = item
375
473
  geom = f.geometry["type"]
376
474
 
377
- if geom == "Polygon" || geom == "MultiPolygon"
475
+ if POLY_GEOMS.include?(geom)
378
476
  f.draw(@image, projection, nil, nil, style)
379
- else
380
- if style[:stroke]
381
- color = GD::Color.rgb(*style[:stroke])
477
+ elsif style[:stroke]
478
+ color = GD::Color.rgb(*style[:stroke])
382
479
 
383
- color = GD::GIS::ColorHelpers.random_vivid if @debug
480
+ color = GD::GIS::ColorHelpers.random_vivid if @debug
384
481
 
385
- width = style[:stroke_width] ? style[:stroke_width].round : 1
386
- width = 1 if width < 1
387
- f.draw(@image, projection, color, width)
388
- end
482
+ width = style[:stroke_width] ? style[:stroke_width].round : 1
483
+ width = 1 if width < 1
484
+ f.draw(@image, projection, color, width)
389
485
  end
390
486
  end
391
487
  end
392
488
  end
393
-
394
489
  end
395
490
  end
396
491
  end
397
-
@@ -1,17 +1,65 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module GD
2
4
  module GIS
5
+ # Coordinate Reference System (CRS) helpers and normalizers.
6
+ #
7
+ # This module defines commonly used CRS identifiers and
8
+ # utilities for normalizing coordinates into a single,
9
+ # consistent representation.
10
+ #
3
11
  module CRS
12
+ # OGC CRS84 (longitude, latitude)
4
13
  CRS84 = "urn:ogc:def:crs:OGC:1.3:CRS84"
14
+
15
+ # EPSG:4326 (latitude, longitude axis order)
5
16
  EPSG4326 = "EPSG:4326"
17
+
18
+ # EPSG:3857 (Web Mercator)
6
19
  EPSG3857 = "EPSG:3857"
7
- GK_ARGENTINA = "EPSG:22195" # Gauss–Krüger Argentina (zone 5 example)
8
20
 
9
- # Normalize any CRS → CRS84 (lon,lat in degrees)
21
+ # Gauss–Krüger Argentina, zone 5
22
+ #
23
+ # Note: This constant represents a *specific* GK zone
24
+ # and is not a generic Gauss–Krüger definition.
25
+ GK_ARGENTINA = "EPSG:22195"
26
+
27
+ # Normalizes coordinates from supported CRS definitions
28
+ # into CRS84 (longitude, latitude in degrees).
29
+ #
30
+ # Supported input CRS:
31
+ # - CRS84
32
+ # - EPSG:4326 (axis order normalization)
33
+ # - EPSG:3857 (Web Mercator)
34
+ # - EPSG:22195 (Gauss–Krüger Argentina, zone 5)
35
+ #
36
+ # All outputs are returned as:
37
+ # [longitude, latitude] in degrees
38
+ #
39
+ # ⚠️ Projection conversions are intended for mapping
40
+ # and visualization, not for high-precision geodesy.
41
+ #
10
42
  class Normalizer
43
+ # Creates a new CRS normalizer.
44
+ #
45
+ # @param crs [String, Symbol, nil]
46
+ # CRS identifier; defaults to CRS84 if nil
11
47
  def initialize(crs)
12
48
  @crs = normalize_name(crs)
13
49
  end
14
50
 
51
+ # Normalizes a coordinate pair into CRS84.
52
+ #
53
+ # @param lon [Numeric]
54
+ # first coordinate (meaning depends on input CRS)
55
+ # @param lat [Numeric]
56
+ # second coordinate (meaning depends on input CRS)
57
+ #
58
+ # @return [Array<Float>]
59
+ # normalized [longitude, latitude] in degrees
60
+ #
61
+ # @raise [RuntimeError]
62
+ # if the CRS is not supported
15
63
  def normalize(lon, lat)
16
64
  case @crs
17
65
  when CRS84
@@ -34,52 +82,67 @@ module GD
34
82
 
35
83
  private
36
84
 
85
+ # Normalizes a CRS name into a comparable string.
86
+ #
87
+ # @param name [Object]
88
+ # @return [String]
37
89
  def normalize_name(name)
38
90
  return CRS84 if name.nil?
91
+
39
92
  name.to_s.strip
40
93
  end
41
94
 
42
- # Web Mercator WGS84
95
+ # Converts Web Mercator coordinates to WGS84.
96
+ #
97
+ # @param x [Numeric] X coordinate in meters
98
+ # @param y [Numeric] Y coordinate in meters
99
+ # @return [Array<Float>] [longitude, latitude] in degrees
43
100
  def mercator_to_wgs84(x, y)
44
101
  r = 6378137.0
45
102
  lon = (x / r) * 180.0 / Math::PI
46
- lat = (2 * Math.atan(Math.exp(y / r)) - Math::PI / 2) * 180.0 / Math::PI
103
+ lat = ((2 * Math.atan(Math.exp(y / r))) - (Math::PI / 2)) * 180.0 / Math::PI
47
104
  [lon, lat]
48
105
  end
49
106
 
50
- # Gauss–Krüger Argentina (Zone 5) WGS84
51
- # This is enough precision for mapping
107
+ # Converts Gauss–Krüger Argentina (zone 5) coordinates to WGS84.
108
+ #
109
+ # This implementation provides sufficient accuracy for
110
+ # cartographic rendering and visualization.
111
+ #
112
+ # @param easting [Numeric] easting (meters)
113
+ # @param northing [Numeric] northing (meters)
114
+ # @return [Array<Float>] [longitude, latitude] in degrees
52
115
  def gk_to_wgs84(easting, northing)
53
116
  # Parameters for Argentina GK Zone 5
54
117
  a = 6378137.0
55
118
  f = 1 / 298.257223563
56
- e2 = 2*f - f*f
57
- lon0 = -60.0 * Math::PI / 180.0 # central meridian zone 5
119
+ e2 = (2 * f) - (f * f)
120
+ lon0 = -60.0 * Math::PI / 180.0 # central meridian zone 5
58
121
 
59
122
  x = easting - 500000.0
60
123
  y = northing
61
124
 
62
125
  m = y
63
- mu = m / (a * (1 - e2/4 - 3*e2*e2/64))
126
+ mu = m / (a * (1 - (e2 / 4) - (3 * e2 * e2 / 64)))
64
127
 
65
128
  e1 = (1 - Math.sqrt(1 - e2)) / (1 + Math.sqrt(1 - e2))
66
129
 
67
- j1 = 3*e1/2 - 27*e1**3/32
68
- j2 = 21*e1**2/16 - 55*e1**4/32
130
+ j1 = (3 * e1 / 2) - (27 * (e1**3) / 32)
131
+ j2 = (21 * (e1**2) / 16) - (55 * (e1**4) / 32)
69
132
 
70
- fp = mu + j1*Math.sin(2*mu) + j2*Math.sin(4*mu)
133
+ fp = mu + (j1 * Math.sin(2 * mu)) + (j2 * Math.sin(4 * mu))
71
134
 
72
- c1 = e2 * Math.cos(fp)**2
135
+ c1 = e2 * (Math.cos(fp)**2)
73
136
  t1 = Math.tan(fp)**2
74
- r1 = a * (1 - e2) / (1 - e2 * Math.sin(fp)**2)**1.5
75
- n1 = a / Math.sqrt(1 - e2 * Math.sin(fp)**2)
137
+ r1 = a * (1 - e2) / ((1 - (e2 * (Math.sin(fp)**2)))**1.5)
138
+ n1 = a / Math.sqrt(1 - (e2 * (Math.sin(fp)**2)))
76
139
 
77
140
  d = x / n1
78
141
 
79
- lat = fp - (n1*Math.tan(fp)/r1) *
80
- (d**2/2 - (5 + 3*t1 + 10*c1)*d**4/24)
142
+ lat = fp - ((n1 * Math.tan(fp) / r1) *
143
+ (((d**2) / 2) - ((5 + (3 * t1) + (10 * c1)) * (d**4) / 24)))
81
144
 
82
- lon = lon0 + (d - (1 + 2*t1 + c1)*d**3/6) / Math.cos(fp)
145
+ lon = lon0 + ((d - ((1 + (2 * t1) + c1) * (d**3) / 6)) / Math.cos(fp))
83
146
 
84
147
  [lon * 180.0 / Math::PI, lat * 180.0 / Math::PI]
85
148
  end
@@ -1,16 +1,59 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "yaml"
2
4
 
3
5
  module GD
4
6
  module GIS
7
+ # Classifies features into semantic layers using rule-based matching.
8
+ #
9
+ # An Ontology maps feature properties to logical layer identifiers
10
+ # (e.g. :water, :road, :park) based on a YAML rule definition.
11
+ #
12
+ # The ontology is intentionally simple and heuristic-based:
13
+ # - Rules are evaluated in order
14
+ # - The first matching rule wins
15
+ # - Matching is case-insensitive and substring-based
16
+ #
17
+ # This design favors robustness and flexibility over strict
18
+ # schema enforcement.
19
+ #
5
20
  class Ontology
21
+ # Creates a new ontology.
22
+ #
23
+ # @param path [String, nil]
24
+ # path to a YAML ontology file; defaults to `ontology.yml`
25
+ # shipped with the gem
26
+ #
27
+ # @raise [Errno::ENOENT] if the file does not exist
28
+ # @raise [Psych::SyntaxError] if the YAML is invalid
6
29
  def initialize(path = nil)
7
30
  path ||= File.expand_path("ontology.yml", __dir__)
8
31
  @rules = YAML.load_file(path)
9
32
  end
10
33
 
34
+ # Classifies a feature into a semantic layer.
35
+ #
36
+ # Properties are matched against ontology rules using
37
+ # case-insensitive substring comparison.
38
+ #
39
+ # @param properties [Hash]
40
+ # feature attributes / tags
41
+ # @param geometry_type [String, nil]
42
+ # GeoJSON geometry type
43
+ #
44
+ # @return [Symbol, nil]
45
+ # semantic layer identifier, or nil if no rule matches
46
+ #
47
+ # @example
48
+ # ontology.classify({ "waterway" => "river" })
49
+ # #=> :water
50
+ #
51
+ # @example
52
+ # ontology.classify({}, geometry_type: "Point")
53
+ # #=> :points
11
54
  def classify(properties, geometry_type: nil)
12
55
  @rules.each do |layer, sources|
13
- sources.each do |source, rules|
56
+ sources.each_value do |rules|
14
57
  rules.each do |key, values|
15
58
  v = (properties[key.to_s] || properties[key.to_sym]).to_s.strip.downcase
16
59
  values = values.map { |x| x.to_s.downcase }
@@ -20,11 +63,11 @@ module GD
20
63
  end
21
64
  end
22
65
 
66
+ # Fallback classification
23
67
  return :points if geometry_type == "Point"
24
68
 
25
69
  nil
26
70
  end
27
-
28
71
  end
29
72
  end
30
73
  end
@@ -1,4 +1,10 @@
1
1
  water:
2
+ osm:
3
+ waterway:
4
+ - river
5
+ - canal
6
+ - stream
7
+
2
8
  ign:
3
9
  objeto:
4
10
  - canal
@@ -26,3 +32,5 @@ track:
26
32
  gps:
27
33
  name:
28
34
  - track
35
+
36
+