underpass 0.0.7 → 0.9.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.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Underpass
4
+ # Post-query filtering of {Feature} objects by tag properties.
5
+ #
6
+ # @example Filter restaurants by cuisine
7
+ # filter = Underpass::Filter.new(features)
8
+ # italian = filter.where(cuisine: 'italian')
9
+ class Filter
10
+ # Creates a new filter for the given features.
11
+ #
12
+ # @param features [Array<Feature>] the features to filter
13
+ def initialize(features)
14
+ @features = features
15
+ end
16
+
17
+ # Returns features whose properties match all given conditions.
18
+ #
19
+ # Conditions can be exact values, regular expressions, or arrays of values.
20
+ #
21
+ # @param conditions [Hash{Symbol => String, Regexp, Array}] tag conditions to match
22
+ # @return [Array<Feature>] features matching all conditions
23
+ #
24
+ # @example Exact match
25
+ # filter.where(cuisine: 'italian')
26
+ # @example Regex match
27
+ # filter.where(name: /pizza/i)
28
+ # @example Array inclusion
29
+ # filter.where(cuisine: ['italian', 'mexican'])
30
+ def where(conditions = {})
31
+ @features.select do |feature|
32
+ conditions.all? { |key, value| match_condition?(feature.properties[key], value) }
33
+ end
34
+ end
35
+
36
+ # Returns features that do not match any of the given conditions.
37
+ #
38
+ # @param conditions [Hash{Symbol => String}] tag conditions to reject
39
+ # @return [Array<Feature>] features not matching any condition
40
+ def reject(conditions = {})
41
+ @features.reject do |feature|
42
+ conditions.any? { |key, value| feature.properties[key] == value.to_s }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def match_condition?(prop_value, condition)
49
+ case condition
50
+ when Regexp then prop_value&.match?(condition)
51
+ when Array then condition.include?(prop_value)
52
+ else prop_value == condition.to_s
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgeo/geo_json'
4
+
5
+ module Underpass
6
+ # Encodes {Feature} arrays as GeoJSON FeatureCollections.
7
+ #
8
+ # @example Export query results to GeoJSON
9
+ # features = Underpass::QL::Query.perform(bbox, query)
10
+ # geojson = Underpass::GeoJSON.encode(features)
11
+ module GeoJSON
12
+ # Encodes an array of features as a GeoJSON FeatureCollection hash.
13
+ #
14
+ # @param features [Array<Feature>] the features to encode
15
+ # @return [Hash] a GeoJSON FeatureCollection
16
+ def self.encode(features)
17
+ geo_features = features.map do |f|
18
+ RGeo::GeoJSON::Feature.new(f.geometry, f.id, f.properties)
19
+ end
20
+ RGeo::GeoJSON.encode(RGeo::GeoJSON::FeatureCollection.new(geo_features))
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Underpass
4
+ # Extracts matching elements from an Overpass API response.
5
+ #
6
+ # A "match" is a response element that has a +tags+ key, indicating it is
7
+ # a tagged OSM element rather than a bare geometry node. Each match is
8
+ # converted into a {Feature} with the appropriate RGeo geometry.
9
+ class Matcher
10
+ # Creates a new matcher for the given response.
11
+ #
12
+ # @param response [QL::Response] a parsed API response
13
+ # @param requested_types [Array<String>, nil] element types to include
14
+ # (e.g. +["node", "way"]+). Defaults to all types when +nil+.
15
+ def initialize(response, requested_types = nil)
16
+ @nodes = response.nodes
17
+ @ways = response.ways
18
+ @relations = response.relations
19
+ @requested_types = requested_types || %w[node way relation]
20
+ end
21
+
22
+ # Returns all matched features as an array.
23
+ #
24
+ # @return [Array<Feature>] the matched features
25
+ def matches
26
+ @matches ||= lazy_matches.to_a
27
+ end
28
+
29
+ # Returns a lazy enumerator of matched features.
30
+ #
31
+ # @return [Enumerator::Lazy<Feature>] lazy enumerator of features
32
+ def lazy_matches
33
+ tagged_elements.lazy.flat_map { |element| features_for(element) }
34
+ end
35
+
36
+ private
37
+
38
+ def tagged_elements
39
+ elements = []
40
+ elements.concat(@nodes.values.select { |n| n.key?(:tags) }) if @requested_types.include?('node')
41
+ elements.concat(@ways.values.select { |w| w.key?(:tags) }) if @requested_types.include?('way')
42
+ elements.concat(@relations.values.select { |r| r.key?(:tags) }) if @requested_types.include?('relation')
43
+ elements
44
+ end
45
+
46
+ def features_for(element)
47
+ case element[:type]
48
+ when 'node'
49
+ [build_feature(Shape.point_from_node(element), element)]
50
+ when 'way'
51
+ [build_feature(way_geometry(element), element)]
52
+ when 'relation'
53
+ relation_features(element)
54
+ else
55
+ []
56
+ end
57
+ end
58
+
59
+ def relation_features(relation)
60
+ geometry = relation_geometry(relation)
61
+ if geometry.is_a?(Array)
62
+ geometry.map { |g| build_feature(g, relation) }
63
+ else
64
+ [build_feature(geometry, relation)]
65
+ end
66
+ end
67
+
68
+ def relation_geometry(relation)
69
+ case relation[:tags][:type]
70
+ when 'multipolygon'
71
+ Shape.multipolygon_from_relation(relation, @ways, @nodes)
72
+ when 'route'
73
+ Shape.multi_line_string_from_relation(relation, @ways, @nodes)
74
+ else
75
+ expand_relation_members(relation)
76
+ end
77
+ end
78
+
79
+ def expand_relation_members(relation)
80
+ relation[:members].filter_map do |member|
81
+ case member[:type]
82
+ when 'node' then Shape.point_from_node(@nodes[member[:ref]])
83
+ when 'way' then way_geometry(@ways[member[:ref]])
84
+ end
85
+ end
86
+ end
87
+
88
+ def way_geometry(way)
89
+ if Shape.open_way?(way)
90
+ Shape.polygon_from_way(way, @nodes)
91
+ else
92
+ Shape.line_string_from_way(way, @nodes)
93
+ end
94
+ end
95
+
96
+ def build_feature(geometry, element)
97
+ Feature.new(
98
+ geometry: geometry,
99
+ properties: element[:tags] || {},
100
+ id: element[:id],
101
+ type: element[:type]
102
+ )
103
+ end
104
+ end
105
+ end
@@ -2,13 +2,26 @@
2
2
 
3
3
  module Underpass
4
4
  module QL
5
- # Bounding box related utilities.
5
+ # Converts RGeo geometries and WKT strings into Overpass QL bounding box syntax.
6
6
  class BoundingBox
7
- # Returns the Overpass query language bounding box string
8
- # when provided with an RGeo geometry
9
- def self.from_geometry(geometry)
10
- r_bb = RGeo::Cartesian::BoundingBox.create_from_geometry(geometry)
11
- "bbox:#{r_bb.min_y},#{r_bb.min_x},#{r_bb.max_y},#{r_bb.max_x}"
7
+ class << self
8
+ # Returns the Overpass QL bounding box string from a WKT string.
9
+ #
10
+ # @param wkt [String] a Well Known Text geometry string
11
+ # @return [String] an Overpass QL bounding box (e.g. +"bbox:47.65,23.669,47.674,23.725"+)
12
+ def from_wkt(wkt)
13
+ geometry = RGeo::Geographic.spherical_factory.parse_wkt(wkt)
14
+ from_geometry(geometry)
15
+ end
16
+
17
+ # Returns the Overpass QL bounding box string from an RGeo geometry.
18
+ #
19
+ # @param geometry [RGeo::Feature::Geometry] an RGeo geometry
20
+ # @return [String] an Overpass QL bounding box (e.g. +"bbox:47.65,23.669,47.674,23.725"+)
21
+ def from_geometry(geometry)
22
+ r_bb = RGeo::Cartesian::BoundingBox.create_from_geometry(geometry)
23
+ "bbox:#{r_bb.min_y},#{r_bb.min_x},#{r_bb.max_y},#{r_bb.max_x}"
24
+ end
12
25
  end
13
26
  end
14
27
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Underpass
4
+ module QL
5
+ # DSL for building Overpass QL queries programmatically.
6
+ #
7
+ # @example Query for restaurants
8
+ # builder = Underpass::QL::Builder.new
9
+ # builder.node('amenity' => 'restaurant')
10
+ # Underpass::QL::Query.perform(bbox, builder)
11
+ #
12
+ # @example Proximity search with around
13
+ # builder = Underpass::QL::Builder.new
14
+ # builder.node('amenity' => 'cafe').around(500, 44.4268, 26.1025)
15
+ class Builder
16
+ # Creates a new empty builder.
17
+ def initialize
18
+ @statements = []
19
+ @around = nil
20
+ end
21
+
22
+ # Adds a node query statement.
23
+ #
24
+ # @param tags [Hash{String => String}] tag filters
25
+ # @return [self] for method chaining
26
+ def node(tags = {})
27
+ @statements << build_statement('node', tags)
28
+ self
29
+ end
30
+
31
+ # Adds a way query statement.
32
+ #
33
+ # @param tags [Hash{String => String}] tag filters
34
+ # @return [self] for method chaining
35
+ def way(tags = {})
36
+ @statements << build_statement('way', tags)
37
+ self
38
+ end
39
+
40
+ # Adds a relation query statement.
41
+ #
42
+ # @param tags [Hash{String => String}] tag filters
43
+ # @return [self] for method chaining
44
+ def relation(tags = {})
45
+ @statements << build_statement('relation', tags)
46
+ self
47
+ end
48
+
49
+ # Adds a node/way/relation (nwr) query statement.
50
+ #
51
+ # @param tags [Hash{String => String}] tag filters
52
+ # @return [self] for method chaining
53
+ def nwr(tags = {})
54
+ @statements << build_statement('nwr', tags)
55
+ self
56
+ end
57
+
58
+ # Sets a proximity filter for all statements.
59
+ #
60
+ # @param radius [Numeric] search radius in meters
61
+ # @param lat_or_point [Numeric, RGeo::Feature::Point] latitude or an RGeo point
62
+ # @param lon [Numeric, nil] longitude (required when +lat_or_point+ is numeric)
63
+ # @return [self] for method chaining
64
+ def around(radius, lat_or_point, lon = nil)
65
+ @around = if lat_or_point.respond_to?(:y)
66
+ { radius: radius, lat: lat_or_point.y, lon: lat_or_point.x }
67
+ else
68
+ { radius: radius, lat: lat_or_point, lon: lon }
69
+ end
70
+ self
71
+ end
72
+
73
+ # Converts the builder into an Overpass QL query string.
74
+ #
75
+ # @return [String] the Overpass QL query
76
+ def to_ql
77
+ if @around
78
+ @statements.map { |s| append_around(s) }.join("\n")
79
+ else
80
+ @statements.join("\n")
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def build_statement(type, tags)
87
+ tag_filters = tags.map { |k, v| "[\"#{k}\"=\"#{v}\"]" }.join
88
+ "#{type}#{tag_filters};"
89
+ end
90
+
91
+ def append_around(statement)
92
+ around_filter = "(around:#{@around[:radius]},#{@around[:lat]},#{@around[:lon]})"
93
+ statement.sub(';', "#{around_filter};")
94
+ end
95
+ end
96
+ end
97
+ end
@@ -1,17 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Underpass
4
+ # Namespace for Overpass Query Language related classes.
4
5
  module QL
5
- # Provides a shortcut method that makes it easy to work with the library
6
+ # High-level entry point for querying the Overpass API.
7
+ #
8
+ # Glues together {Request}, {Client}, {Response}, {QueryAnalyzer}, and
9
+ # {Matcher} to provide a single-call interface.
10
+ #
11
+ # @example Query with a bounding box
12
+ # features = Underpass::QL::Query.perform(bbox, 'way["building"="yes"];')
13
+ #
14
+ # @example Query with a named area
15
+ # features = Underpass::QL::Query.perform_in_area('Romania', 'node["place"="city"];')
6
16
  class Query
7
- # Shortcut method that glues together the whole library.
8
- # * +bounding_box+ an RGeo polygon
9
- # * +query+ is the Overpass QL query
17
+ # Queries the Overpass API within a bounding box.
18
+ #
19
+ # @param bounding_box [RGeo::Feature::Geometry] an RGeo polygon defining the search area
20
+ # @param query [String, Builder] an Overpass QL query string or a {Builder} instance
21
+ # @return [Array<Feature>] the matched features
22
+ # @raise [RateLimitError] when rate limited after exhausting retries
23
+ # @raise [TimeoutError] when the API times out after exhausting retries
24
+ # @raise [ApiError] when the API returns an unexpected error
10
25
  def self.perform(bounding_box, query)
11
- op_bbox = Underpass::QL::BoundingBox.from_geometry(bounding_box)
12
- response = Underpass::QL::Request.new(query, op_bbox).run
13
- Underpass::QL::Parser.new(response).parse.matches
26
+ query_string = resolve_query(query)
27
+ op_bbox = Underpass::QL::BoundingBox.from_geometry(bounding_box)
28
+ request = Underpass::QL::Request.new(query_string, op_bbox)
29
+ execute(request, query_string)
14
30
  end
31
+
32
+ # Queries the Overpass API within a named area (e.g. "Romania").
33
+ #
34
+ # @param area_name [String] an OSM area name
35
+ # @param query [String, Builder] an Overpass QL query string or a {Builder} instance
36
+ # @return [Array<Feature>] the matched features
37
+ # @raise [RateLimitError] when rate limited after exhausting retries
38
+ # @raise [TimeoutError] when the API times out after exhausting retries
39
+ # @raise [ApiError] when the API returns an unexpected error
40
+ def self.perform_in_area(area_name, query)
41
+ query_string = resolve_query(query)
42
+ request = Underpass::QL::Request.new(query_string, nil, area_name: area_name)
43
+ execute(request, query_string)
44
+ end
45
+
46
+ def self.resolve_query(query)
47
+ query.respond_to?(:to_ql) ? query.to_ql : query
48
+ end
49
+ private_class_method :resolve_query
50
+
51
+ def self.execute(request, query_string)
52
+ api_response = Underpass::Client.perform(request)
53
+ response = Underpass::QL::Response.new(api_response)
54
+ query_analyzer = Underpass::QL::QueryAnalyzer.new(query_string)
55
+ requested_types = query_analyzer.requested_types
56
+ matcher = Underpass::Matcher.new(response, requested_types)
57
+
58
+ matcher.matches
59
+ end
60
+ private_class_method :execute
15
61
  end
16
62
  end
17
63
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Underpass
4
+ module QL
5
+ # Analyzes an Overpass QL query string to determine which element types
6
+ # (node, way, relation) are requested.
7
+ #
8
+ # Used internally by {Query} to pass type hints to {Matcher}.
9
+ class QueryAnalyzer
10
+ # @return [Array<String>] the recognized OSM element types
11
+ MATCH_TYPES = %w[node way relation].freeze
12
+
13
+ # Creates a new analyzer for the given query string.
14
+ #
15
+ # @param query [String, nil] an Overpass QL query string
16
+ def initialize(query)
17
+ @query = query
18
+ end
19
+
20
+ # Returns the element types requested in the query.
21
+ #
22
+ # Falls back to all types when the query is empty or contains
23
+ # no recognized type keywords.
24
+ #
25
+ # @return [Array<String>] requested types (e.g. +["node", "way"]+)
26
+ def requested_types
27
+ return MATCH_TYPES if empty_query?
28
+
29
+ types = parse_types_from_query
30
+ types.empty? ? MATCH_TYPES : types
31
+ end
32
+
33
+ private
34
+
35
+ def empty_query?
36
+ @query.nil? || @query.strip.empty?
37
+ end
38
+
39
+ def parse_types_from_query
40
+ lines = @query.strip.split(';').map(&:strip).reject(&:empty?)
41
+ lines.map { |line| first_word(line) }
42
+ .select { |word| MATCH_TYPES.include?(word) }
43
+ .uniq
44
+ end
45
+
46
+ def first_word(line)
47
+ line.split(/[\[\s]+/).first
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,34 +2,67 @@
2
2
 
3
3
  module Underpass
4
4
  module QL
5
- # Deals with performing the Overpass API request
5
+ # Prepares the full Overpass QL query string from a user query,
6
+ # bounding box, and configuration options.
7
+ #
8
+ # Supports both bounding-box and named-area query templates.
6
9
  class Request
7
- API_URI = 'https://overpass-api.de/api/interpreter'
10
+ # @api private
8
11
  QUERY_TEMPLATE = <<-TEMPLATE
9
- [out:json][timeout:25]BBOX;
12
+ [out:json][timeout:TIMEOUT]BBOX;
10
13
  (
11
14
  QUERY
12
15
  );
13
16
  out body;
14
- >;
17
+ RECURSE
15
18
  out skel qt;
16
19
  TEMPLATE
17
20
 
18
- def initialize(query, bbox)
21
+ # @api private
22
+ AREA_QUERY_TEMPLATE = <<-TEMPLATE
23
+ [out:json][timeout:TIMEOUT];
24
+ area["name"="AREA_NAME"]->.searchArea;
25
+ (
26
+ QUERY(area.searchArea);
27
+ );
28
+ out body;
29
+ RECURSE
30
+ out skel qt;
31
+ TEMPLATE
32
+
33
+ # Creates a new request.
34
+ #
35
+ # @param query [String] the Overpass QL query body
36
+ # @param bbox [String, nil] the bounding box string from {BoundingBox}
37
+ # @param recurse [String, nil] the recurse operator (default: +">"+)
38
+ # @param area_name [String, nil] an OSM area name for area-based queries
39
+ def initialize(query, bbox = nil, recurse: '>', area_name: nil)
19
40
  @overpass_query = query
20
- @global_bbox ||= "[#{bbox}]"
41
+ @global_bbox = bbox ? "[#{bbox}]" : ''
42
+ @recurse = recurse
43
+ @area_name = area_name
21
44
  end
22
45
 
23
- # Performs the API request
24
- def run
25
- Net::HTTP.post_form(URI(API_URI), data: build_query)
46
+ # Converts the request into a complete Overpass QL query string.
47
+ #
48
+ # @return [String] the full query string ready for the API
49
+ def to_query
50
+ template = @area_name ? AREA_QUERY_TEMPLATE : QUERY_TEMPLATE
51
+ timeout = Underpass.configuration.timeout.to_s
52
+
53
+ result = template.sub('TIMEOUT', timeout)
54
+ result = result.sub('AREA_NAME', @area_name) if @area_name
55
+ result = result.sub('BBOX', @global_bbox) unless @area_name
56
+ result.sub('QUERY', @overpass_query)
57
+ .sub('RECURSE', recurse_statement)
26
58
  end
27
59
 
28
60
  private
29
61
 
30
- def build_query
31
- QUERY_TEMPLATE.sub('BBOX', @global_bbox)
32
- .sub('QUERY', @overpass_query)
62
+ def recurse_statement
63
+ return '' unless @recurse
64
+
65
+ "#{@recurse};\n"
33
66
  end
34
67
  end
35
68
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Underpass
4
+ module QL
5
+ # Parses the JSON body of an Overpass API response into
6
+ # node, way, and relation lookup hashes keyed by element ID.
7
+ class Response
8
+ # Creates a new response by parsing the API response body.
9
+ #
10
+ # @param api_response [Net::HTTPResponse] the raw HTTP response
11
+ def initialize(api_response)
12
+ parsed_json = JSON.parse(api_response.body, symbolize_names: true)
13
+ @elements = parsed_json[:elements]
14
+ end
15
+
16
+ # Returns all node elements as a hash keyed by ID.
17
+ #
18
+ # @return [Hash{Integer => Hash}] node elements
19
+ def nodes
20
+ mapped_hash('node')
21
+ end
22
+
23
+ # Returns all way elements as a hash keyed by ID.
24
+ #
25
+ # @return [Hash{Integer => Hash}] way elements
26
+ def ways
27
+ mapped_hash('way')
28
+ end
29
+
30
+ # Returns all relation elements as a hash keyed by ID.
31
+ #
32
+ # @return [Hash{Integer => Hash}] relation elements
33
+ def relations
34
+ mapped_hash('relation')
35
+ end
36
+
37
+ private
38
+
39
+ def mapped_hash(type)
40
+ mapped_elements = elements_of_type(type).map do |element|
41
+ [element[:id], element]
42
+ end
43
+ mapped_elements.to_h
44
+ end
45
+
46
+ def elements_of_type(type)
47
+ @elements.select { |e| e[:type] == type }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Underpass
4
+ # Converts OSM element data into RGeo geometry objects.
5
+ #
6
+ # All methods operate on the parsed response hashes produced by
7
+ # {QL::Response} and return RGeo geometries built with a WGS 84
8
+ # spherical factory.
9
+ class Shape
10
+ class << self
11
+ # Checks whether a way forms a closed ring (first node equals last node).
12
+ #
13
+ # @param way [Hash] a parsed way element
14
+ # @return [Boolean] +true+ if the way is closed
15
+ def open_way?(way)
16
+ way[:nodes].first == way[:nodes].last
17
+ end
18
+
19
+ # Builds an RGeo polygon from a closed way.
20
+ #
21
+ # @param way [Hash] a parsed way element
22
+ # @param nodes [Hash{Integer => Hash}] node lookup table
23
+ # @return [RGeo::Feature::Polygon] the polygon geometry
24
+ def polygon_from_way(way, nodes)
25
+ factory.polygon(line_string_from_way(way, nodes))
26
+ end
27
+
28
+ # Builds an RGeo line string from a way's node references.
29
+ #
30
+ # @param way [Hash] a parsed way element
31
+ # @param nodes [Hash{Integer => Hash}] node lookup table
32
+ # @return [RGeo::Feature::LineString] the line string geometry
33
+ def line_string_from_way(way, nodes)
34
+ factory.line_string(points_from_node_ids(way[:nodes], nodes))
35
+ end
36
+
37
+ # Builds an RGeo point from a node element.
38
+ #
39
+ # @param node [Hash] a parsed node element with +:lon+ and +:lat+ keys
40
+ # @return [RGeo::Feature::Point] the point geometry
41
+ def point_from_node(node)
42
+ factory.point(node[:lon], node[:lat])
43
+ end
44
+
45
+ # Assembles a multipolygon from a relation with outer and inner members.
46
+ #
47
+ # @param relation [Hash] a parsed relation element
48
+ # @param ways [Hash{Integer => Hash}] way lookup table
49
+ # @param nodes [Hash{Integer => Hash}] node lookup table
50
+ # @return [RGeo::Feature::Polygon, RGeo::Feature::MultiPolygon] a polygon
51
+ # if only one outer ring, otherwise a multi-polygon
52
+ def multipolygon_from_relation(relation, ways, nodes)
53
+ outer_rings = build_rings(members_by_role(relation, 'outer'), ways, nodes)
54
+ inner_rings = build_rings(members_by_role(relation, 'inner'), ways, nodes)
55
+
56
+ polygons = outer_rings.map do |outer_ring|
57
+ factory.polygon(outer_ring, matching_inner_rings(outer_ring, inner_rings))
58
+ end
59
+
60
+ return polygons.first if polygons.size == 1
61
+
62
+ factory.multi_polygon(polygons)
63
+ end
64
+
65
+ # Assembles a multi-line-string from a route relation's way members.
66
+ #
67
+ # @param relation [Hash] a parsed relation element
68
+ # @param ways [Hash{Integer => Hash}] way lookup table
69
+ # @param nodes [Hash{Integer => Hash}] node lookup table
70
+ # @return [RGeo::Feature::MultiLineString] the multi-line-string geometry
71
+ def multi_line_string_from_relation(relation, ways, nodes)
72
+ way_members = relation[:members].select { |m| m[:type] == 'way' }
73
+ line_strings = way_members.filter_map do |member|
74
+ way = ways[member[:ref]]
75
+ next unless way
76
+
77
+ line_string_from_way(way, nodes)
78
+ end
79
+
80
+ factory.multi_line_string(line_strings)
81
+ end
82
+
83
+ # Returns the shared RGeo spherical factory (SRID 4326).
84
+ #
85
+ # @return [RGeo::Geographic::SphericalFactory] the factory instance
86
+ def factory
87
+ @factory ||= RGeo::Geographic.spherical_factory(srid: 4326)
88
+ end
89
+
90
+ private
91
+
92
+ def points_from_node_ids(node_ids, nodes)
93
+ node_ids.map { |n| factory.point(nodes[n][:lon], nodes[n][:lat]) }
94
+ end
95
+
96
+ def members_by_role(relation, role)
97
+ relation[:members]
98
+ .select { |m| m[:type] == 'way' && m[:role] == role }
99
+ .map { |m| m[:ref] }
100
+ end
101
+
102
+ def build_rings(way_ids, ways, nodes)
103
+ return [] if way_ids.empty?
104
+
105
+ sequences = WayChain.new(way_ids, ways).merged_sequences
106
+ sequences.map { |ids| factory.linear_ring(points_from_node_ids(ids, nodes)) }
107
+ end
108
+
109
+ def matching_inner_rings(outer_ring, inner_rings)
110
+ outer_polygon = factory.polygon(outer_ring)
111
+ inner_rings.select do |inner_ring|
112
+ point = inner_ring.points.first
113
+ outer_polygon.contains?(factory.point(point.x, point.y))
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end