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.
- checksums.yaml +5 -5
- data/README.md +428 -18
- data/lib/underpass/cache.rb +45 -0
- data/lib/underpass/client.rb +67 -0
- data/lib/underpass/configuration.rb +23 -0
- data/lib/underpass/errors.rb +15 -0
- data/lib/underpass/feature.rb +34 -0
- data/lib/underpass/filter.rb +56 -0
- data/lib/underpass/geo_json.rb +23 -0
- data/lib/underpass/matcher.rb +105 -0
- data/lib/underpass/ql/bounding_box.rb +19 -6
- data/lib/underpass/ql/builder.rb +97 -0
- data/lib/underpass/ql/query.rb +53 -7
- data/lib/underpass/ql/query_analyzer.rb +51 -0
- data/lib/underpass/ql/request.rb +45 -12
- data/lib/underpass/ql/response.rb +51 -0
- data/lib/underpass/shape.rb +118 -0
- data/lib/underpass/version.rb +2 -2
- data/lib/underpass/way_chain.rb +71 -0
- data/lib/underpass.rb +65 -19
- metadata +25 -103
- data/lib/underpass/ql/parser.rb +0 -72
- data/lib/underpass/ql/ql.rb +0 -15
- data/lib/underpass/ql/shape.rb +0 -40
|
@@ -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
|
-
#
|
|
5
|
+
# Converts RGeo geometries and WKT strings into Overpass QL bounding box syntax.
|
|
6
6
|
class BoundingBox
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"bbox
|
|
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
|
data/lib/underpass/ql/query.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
Underpass::QL::
|
|
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
|
data/lib/underpass/ql/request.rb
CHANGED
|
@@ -2,34 +2,67 @@
|
|
|
2
2
|
|
|
3
3
|
module Underpass
|
|
4
4
|
module QL
|
|
5
|
-
#
|
|
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
|
-
|
|
10
|
+
# @api private
|
|
8
11
|
QUERY_TEMPLATE = <<-TEMPLATE
|
|
9
|
-
[out:json][timeout:
|
|
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
|
-
|
|
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
|
|
41
|
+
@global_bbox = bbox ? "[#{bbox}]" : ''
|
|
42
|
+
@recurse = recurse
|
|
43
|
+
@area_name = area_name
|
|
21
44
|
end
|
|
22
45
|
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|