nswtopo 2.0.0 → 3.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/COPYING +70 -83
- data/bin/nswtopo +227 -116
- data/docs/README.md +1 -12
- data/docs/add.md +1 -1
- data/docs/config.md +1 -1
- data/docs/contours.md +3 -1
- data/docs/init.md +8 -0
- data/docs/inspect.md +103 -0
- data/docs/move.md +9 -0
- data/docs/render.md +16 -7
- data/docs/scrape.md +67 -0
- data/docs/spot-heights.md +6 -2
- data/lib/nswtopo/archive.rb +50 -41
- data/lib/nswtopo/chrome.rb +232 -0
- data/lib/nswtopo/commands/add.rb +106 -0
- data/lib/nswtopo/commands/config.rb +38 -0
- data/lib/nswtopo/commands/inspect.rb +74 -0
- data/lib/nswtopo/commands/layers.rb +22 -0
- data/lib/nswtopo/commands/scrape.rb +79 -0
- data/lib/nswtopo/commands.rb +57 -0
- data/lib/nswtopo/dither.rb +5 -3
- data/lib/nswtopo/font.rb +46 -21
- data/lib/nswtopo/formats/gemf.rb +42 -0
- data/lib/nswtopo/formats/kmz.rb +26 -24
- data/lib/nswtopo/formats/mbtiles.rb +5 -41
- data/lib/nswtopo/formats/pdf.rb +112 -18
- data/lib/nswtopo/formats/svg.rb +114 -45
- data/lib/nswtopo/formats/svgz.rb +2 -2
- data/lib/nswtopo/formats/zip.rb +33 -23
- data/lib/nswtopo/formats.rb +77 -32
- data/lib/nswtopo/geometry/overlap.rb +1 -32
- data/lib/nswtopo/geometry/r_tree.rb +16 -10
- data/lib/nswtopo/geometry/segment.rb +3 -3
- data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +5 -6
- data/lib/nswtopo/geometry/vector_sequence.rb +7 -6
- data/lib/nswtopo/gis/arcgis/connection.rb +56 -0
- data/lib/nswtopo/gis/arcgis/layer/map.rb +163 -0
- data/lib/nswtopo/gis/arcgis/layer/query.rb +87 -0
- data/lib/nswtopo/gis/arcgis/layer/renderer.rb +66 -0
- data/lib/nswtopo/gis/arcgis/layer/statistics.rb +15 -0
- data/lib/nswtopo/gis/arcgis/layer.rb +201 -0
- data/lib/nswtopo/gis/arcgis/service.rb +57 -0
- data/lib/nswtopo/gis/arcgis.rb +3 -0
- data/lib/nswtopo/gis/dem.rb +13 -12
- data/lib/nswtopo/gis/esri_hdr.rb +8 -2
- data/lib/nswtopo/gis/geojson/collection.rb +45 -21
- data/lib/nswtopo/gis/geojson/multi_line_string.rb +2 -24
- data/lib/nswtopo/gis/geojson/multi_polygon.rb +2 -53
- data/lib/nswtopo/gis/geojson/polygon.rb +15 -0
- data/lib/nswtopo/gis/geojson.rb +12 -3
- data/lib/nswtopo/gis/gps/kml.rb +25 -19
- data/lib/nswtopo/gis/gps.rb +2 -0
- data/lib/nswtopo/gis/projection.rb +35 -24
- data/lib/nswtopo/gis/shapefile.rb +89 -16
- data/lib/nswtopo/gis.rb +1 -2
- data/lib/nswtopo/helpers/array.rb +0 -11
- data/lib/nswtopo/helpers/colour.rb +34 -14
- data/lib/nswtopo/layer/arcgis_raster.rb +44 -48
- data/lib/nswtopo/layer/colour_mask.rb +5 -0
- data/lib/nswtopo/layer/contour.rb +35 -28
- data/lib/nswtopo/layer/control.rb +2 -7
- data/lib/nswtopo/layer/declination.rb +9 -9
- data/lib/nswtopo/layer/feature.rb +36 -22
- data/lib/nswtopo/layer/grid.rb +30 -27
- data/lib/nswtopo/layer/import.rb +1 -21
- data/lib/nswtopo/layer/labels/barrier.rb +39 -0
- data/lib/nswtopo/layer/labels.rb +551 -383
- data/lib/nswtopo/layer/mask_render.rb +37 -0
- data/lib/nswtopo/layer/overlay.rb +2 -2
- data/lib/nswtopo/layer/raster.rb +31 -41
- data/lib/nswtopo/layer/raster_import.rb +17 -0
- data/lib/nswtopo/layer/raster_render.rb +15 -0
- data/lib/nswtopo/layer/relief.rb +27 -95
- data/lib/nswtopo/layer/spot.rb +63 -62
- data/lib/nswtopo/layer/vector/cutout.rb +15 -0
- data/lib/nswtopo/layer/vector/knockout.rb +16 -0
- data/lib/nswtopo/layer/vector.rb +121 -89
- data/lib/nswtopo/layer/vegetation.rb +39 -34
- data/lib/nswtopo/layer.rb +30 -16
- data/lib/nswtopo/map.rb +204 -109
- data/lib/nswtopo/os.rb +5 -27
- data/lib/nswtopo/tiled_web_map.rb +54 -0
- data/lib/nswtopo/tree_indenter.rb +27 -0
- data/lib/nswtopo/version.rb +27 -2
- data/lib/nswtopo.rb +7 -196
- metadata +39 -20
- data/lib/nswtopo/font/chrome.rb +0 -59
- data/lib/nswtopo/font/generic.rb +0 -25
- data/lib/nswtopo/gis/arcgis_server/connection.rb +0 -52
- data/lib/nswtopo/gis/arcgis_server.rb +0 -155
- data/lib/nswtopo/gis/geojson/multi_point.rb +0 -12
- data/lib/nswtopo/gis/world_file.rb +0 -19
- data/lib/nswtopo/layer/labels/fence.rb +0 -20
@@ -0,0 +1,201 @@
|
|
1
|
+
require_relative 'layer/query'
|
2
|
+
require_relative 'layer/map'
|
3
|
+
require_relative 'layer/statistics'
|
4
|
+
require_relative 'layer/renderer'
|
5
|
+
|
6
|
+
module NSWTopo
|
7
|
+
module ArcGIS
|
8
|
+
class Layer
|
9
|
+
FIELD_TYPES = %W[esriFieldTypeOID esriFieldTypeInteger esriFieldTypeSmallInteger esriFieldTypeDouble esriFieldTypeSingle esriFieldTypeString esriFieldTypeGUID esriFieldTypeDate].to_set
|
10
|
+
NoLayerError = Class.new RuntimeError
|
11
|
+
|
12
|
+
def initialize(service, id: nil, layer: nil, where: nil, fields: nil, launder: nil, truncate: nil, decode: nil, mixed: true, geometry: nil, unique: nil)
|
13
|
+
raise NoLayerError, "no ArcGIS layer name or url provided" unless layer || id
|
14
|
+
@id, @name = service["layers"].find do |info|
|
15
|
+
layer ? String(layer) == info["name"] : Integer(id) == info["id"]
|
16
|
+
end&.values_at("id", "name")
|
17
|
+
raise "ArcGIS layer does not exist: #{layer || id}" unless @id
|
18
|
+
|
19
|
+
@service, @where, @decode, @mixed, @geometry, @unique = service, where, decode, mixed, geometry, unique
|
20
|
+
|
21
|
+
@layer = get_json @id
|
22
|
+
raise "ArcGIS layer is not a feature layer: #{@name}" unless @layer["type"] == "Feature Layer"
|
23
|
+
|
24
|
+
@geometry_type = @layer["geometryType"]
|
25
|
+
|
26
|
+
date_fields = @layer["fields"].select do |field|
|
27
|
+
"esriFieldTypeDate" == field["type"]
|
28
|
+
end.map do |field|
|
29
|
+
field["name"]
|
30
|
+
end.to_set
|
31
|
+
|
32
|
+
@fields = fields&.map do |name|
|
33
|
+
@layer["fields"].find(-> { raise "invalid field name: #{name}" }) do |field|
|
34
|
+
field.values_at("alias", "name").include? name
|
35
|
+
end.fetch("name")
|
36
|
+
end
|
37
|
+
|
38
|
+
[[%w[typeIdField], %w[subtypeField subtypeFieldName]], %w[types subtypes], %w[id code]].transpose.map do |name_keys, lookup_key, value_key|
|
39
|
+
next @layer.values_at(*name_keys).compact.reject(&:empty?).first, @layer[lookup_key], value_key
|
40
|
+
end.find do |name_or_alias, lookup, value_key|
|
41
|
+
name_or_alias && lookup&.any?
|
42
|
+
end&.tap do |name_or_alias, lookup, value_key|
|
43
|
+
@type_field = @layer["fields"].find do |field|
|
44
|
+
field.values_at("alias", "name").compact.include? name_or_alias
|
45
|
+
end&.fetch("name")
|
46
|
+
|
47
|
+
@type_values = lookup.map do |type|
|
48
|
+
type.values_at value_key, "name"
|
49
|
+
end.to_h
|
50
|
+
|
51
|
+
@subtype_values = lookup.map do |type|
|
52
|
+
type.values_at value_key, "domains"
|
53
|
+
end.map do |code, domains|
|
54
|
+
coded_values = domains.map do |name, domain|
|
55
|
+
[name, domain["codedValues"]]
|
56
|
+
end.select(&:last).map do |name, pairs|
|
57
|
+
values = pairs.map do |pair|
|
58
|
+
pair.values_at "code", "name"
|
59
|
+
end.to_h
|
60
|
+
[name, values]
|
61
|
+
end.to_h
|
62
|
+
[code, coded_values]
|
63
|
+
end.to_h
|
64
|
+
|
65
|
+
@subtype_fields = @subtype_values.values.flat_map(&:keys).uniq
|
66
|
+
end
|
67
|
+
|
68
|
+
@coded_values = @layer["fields"].map do |field|
|
69
|
+
[field["name"], field.dig("domain", "codedValues")]
|
70
|
+
end.select(&:last).map do |name, pairs|
|
71
|
+
values = pairs.map do |pair|
|
72
|
+
pair.values_at "code", "name"
|
73
|
+
end.to_h
|
74
|
+
[name, values]
|
75
|
+
end.to_h
|
76
|
+
|
77
|
+
@rename = @layer["fields"].map do |field|
|
78
|
+
field["name"]
|
79
|
+
end.map do |name|
|
80
|
+
next name, launder ? name.downcase.gsub(/[^\w]+/, ?_) : name
|
81
|
+
end.map do |name, substitute|
|
82
|
+
next name, truncate ? substitute.slice(0...truncate) : substitute
|
83
|
+
end.sort_by do |name, substitute|
|
84
|
+
[@fields&.include?(name) ? 0 : 1, substitute == name ? 0 : 1]
|
85
|
+
end.inject(Hash[]) do |lookup, (name, substitute)|
|
86
|
+
suffix, index, candidate = "_2", 3, substitute
|
87
|
+
while lookup.key? candidate
|
88
|
+
suffix, index, candidate = "_#{index}", index + 1, (truncate ? substitute.slice(0, truncate - suffix.length) : substitute) + suffix
|
89
|
+
raise "can't individualise field name: #{name}" if truncate && suffix.length >= truncate
|
90
|
+
end
|
91
|
+
lookup.merge candidate => name
|
92
|
+
end.invert.to_proc
|
93
|
+
|
94
|
+
@revalue = lambda do |name, value, properties|
|
95
|
+
case
|
96
|
+
when %w[null Null NULL <null> <Null> <NULL>].include?(value)
|
97
|
+
nil
|
98
|
+
when value.nil?
|
99
|
+
nil
|
100
|
+
when date_fields === name
|
101
|
+
Time.at(value / 1000).utc.iso8601
|
102
|
+
when !decode
|
103
|
+
value
|
104
|
+
when @type_field == name
|
105
|
+
@type_values[value]
|
106
|
+
when lookup = @subtype_values&.dig(properties[@type_field], name)
|
107
|
+
lookup[value]
|
108
|
+
when lookup = @coded_values.dig(name)
|
109
|
+
lookup[value]
|
110
|
+
else value
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
case @layer["capabilities"]
|
115
|
+
when /Query/ then extend Query, @layer["supportsStatistics"] ? Statistics : Renderer
|
116
|
+
when /Map/ then extend Map, Renderer
|
117
|
+
else raise "ArcGIS layer does not include Query or Map capability: #{@name}"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
extend Forwardable
|
122
|
+
delegate %i[get get_json projection] => :@service
|
123
|
+
attr_reader :count
|
124
|
+
|
125
|
+
def extra_field
|
126
|
+
case
|
127
|
+
when !@decode || !@type_field || !@fields
|
128
|
+
when @fields.include?(@type_field)
|
129
|
+
when (@subtype_fields & @fields).any? then @type_field
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def decode(attributes)
|
134
|
+
attributes.map do |name, value|
|
135
|
+
[name, @revalue[name, value, attributes]]
|
136
|
+
end.to_h.slice(*@fields).then do |decoded|
|
137
|
+
attributes.replace decoded
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def transform(feature)
|
142
|
+
decode(feature.properties).transform_keys!(&@rename)
|
143
|
+
end
|
144
|
+
|
145
|
+
def paged(per_page: nil)
|
146
|
+
per_page = [*per_page, *@layer["maxRecordCount"], 500].min
|
147
|
+
Enumerator::Lazy.new pages(per_page) do |yielder, page|
|
148
|
+
page.each(&method(:transform))
|
149
|
+
yielder << page
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def features(**options, &block)
|
154
|
+
paged(**options).inject do |collection, page|
|
155
|
+
yield collection.count, self.count if block_given?
|
156
|
+
collection.merge! page
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def join_clauses(*clauses)
|
161
|
+
"(" << clauses.join(") AND (") << ")" if clauses.any?
|
162
|
+
end
|
163
|
+
|
164
|
+
def codes
|
165
|
+
pairs = lambda do |hash|
|
166
|
+
hash.keys.zip(hash.values.map(&:sort).map(&:zip)).to_h
|
167
|
+
end
|
168
|
+
@coded_values.then(&pairs).tap do |result|
|
169
|
+
next unless @type_field
|
170
|
+
codes, lookups = @subtype_values.sort.transpose
|
171
|
+
result[@type_field] = @type_values.slice(*codes).zip lookups.map(&pairs)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def counts
|
176
|
+
classify(*@fields, *extra_field).each do |attributes, count|
|
177
|
+
decode attributes
|
178
|
+
end.group_by(&:first).map do |attributes, attributes_counts|
|
179
|
+
[attributes, attributes_counts.sum(&:last)]
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def info
|
184
|
+
@layer.slice("name", "id").tap do |info|
|
185
|
+
info["geometry"] = case @geometry_type
|
186
|
+
when "esriGeometryPoint" then "Point"
|
187
|
+
when "esriGeometryMultipoint" then "Multipoint"
|
188
|
+
when "esriGeometryPolyline" then "LineString"
|
189
|
+
when "esriGeometryPolygon" then "Polygon"
|
190
|
+
else @geometry_type.delete_prefix("esriGeometry")
|
191
|
+
end
|
192
|
+
info["EPSG"] = @service["spatialReference"].values_at("latestWkid", "wkid").compact.first
|
193
|
+
info["features"] = count
|
194
|
+
info["fields"] = @layer["fields"].map do |field|
|
195
|
+
[field["name"], field["type"].delete_prefix("esriFieldType")]
|
196
|
+
end.sort_by(&:first).to_h if @layer["fields"]&.any?
|
197
|
+
end.compact
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module NSWTopo
|
2
|
+
module ArcGIS
|
3
|
+
class Service
|
4
|
+
SERVICE = /^(?:MapServer|FeatureServer|ImageServer)$/
|
5
|
+
InvalidURLError = Class.new RuntimeError
|
6
|
+
|
7
|
+
def self.check_uri(url)
|
8
|
+
uri = URI.parse url
|
9
|
+
return unless URI::HTTP === uri
|
10
|
+
return unless uri.path
|
11
|
+
instance, (id, *) = uri.path.split(?/).slice_after(SERVICE).take(2)
|
12
|
+
return unless SERVICE === instance&.last
|
13
|
+
return unless !id || id =~ /^\d+$/
|
14
|
+
return uri, instance.join(?/), id
|
15
|
+
rescue URI::Error
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.===(string)
|
19
|
+
uri, service_path, id = check_uri string
|
20
|
+
uri != nil
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(url)
|
24
|
+
uri, service_path, @id = Service.check_uri url
|
25
|
+
raise InvalidURLError, "invalid ArcGIS server URL: %s" % url unless uri
|
26
|
+
@connection = Connection.new uri, service_path
|
27
|
+
@service = get_json ""
|
28
|
+
|
29
|
+
@projection = case
|
30
|
+
when wkt = @service.dig("spatialReference", "wkt") then Projection.new(wkt)
|
31
|
+
when wkid = @service.dig("spatialReference", "latestWkid") then Projection.new("EPSG:#{wkid}")
|
32
|
+
when wkid = @service.dig("spatialReference", "wkid") then Projection.new("EPSG:#{wkid == 102100 ? 3857 : wkid}")
|
33
|
+
else raise "no spatial reference found: #{uri}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
extend Forwardable
|
38
|
+
delegate %i[get get_json] => :@connection
|
39
|
+
delegate :[] => :@service
|
40
|
+
attr_reader :projection
|
41
|
+
|
42
|
+
def layer(id: @id, **options)
|
43
|
+
Layer.new self, id: id, **options
|
44
|
+
end
|
45
|
+
|
46
|
+
def layer_info
|
47
|
+
children = @service["layers"].group_by do |layer|
|
48
|
+
layer["parentLayerId"] || -1
|
49
|
+
end
|
50
|
+
tree = lambda do |layer|
|
51
|
+
[layer.values_at("id", "name").join(": "), children.fetch(layer["id"], []).map(&tree)]
|
52
|
+
end
|
53
|
+
children[-1].map(&tree)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
data/lib/nswtopo/gis/dem.rb
CHANGED
@@ -5,7 +5,7 @@ module NSWTopo
|
|
5
5
|
def get_dem(temp_dir, dem_path)
|
6
6
|
txt_path = temp_dir / "dem.txt"
|
7
7
|
vrt_path = temp_dir / "dem.vrt"
|
8
|
-
|
8
|
+
cutline = @map.cutline(**margin)
|
9
9
|
|
10
10
|
Dir.chdir(@source ? @source.parent : Pathname.pwd) do
|
11
11
|
log_update "%s: examining DEM" % @name
|
@@ -18,18 +18,20 @@ module NSWTopo
|
|
18
18
|
end.group_by do |path, info|
|
19
19
|
Projection.new info.dig("coordinateSystem", "wkt")
|
20
20
|
end.map.with_index do |(projection, rasters), index|
|
21
|
-
raise "DEM data not in planar projection with metre units" unless projection.
|
21
|
+
raise "DEM data not in planar projection with metre units" unless projection.metres?
|
22
22
|
|
23
23
|
paths, resolutions = rasters.map do |path, info|
|
24
24
|
[path, info["geoTransform"].values_at(1, 2).norm]
|
25
25
|
end.sort_by(&:last).transpose
|
26
26
|
|
27
27
|
txt_path.write paths.reverse.join(?\n)
|
28
|
-
@
|
28
|
+
@mm_per_px ||= @map.to_mm(resolutions.first)
|
29
29
|
|
30
30
|
indexed_dem_path = temp_dir / "dem.#{index}.tif"
|
31
|
-
OS.gdalbuildvrt "-overwrite", "-input_file_list", txt_path, vrt_path
|
32
|
-
OS.gdalwarp "-t_srs", @map.projection, "-
|
31
|
+
OS.gdalbuildvrt "-overwrite", "-allow_projection_difference", "-a_srs", projection, "-input_file_list", txt_path, vrt_path
|
32
|
+
OS.gdalwarp "-t_srs", @map.projection, "-tr", @mm_per_px, @mm_per_px, "-r", "bilinear", "-cutline", "GeoJSON:/vsistdin/", "-crop_to_cutline", vrt_path, indexed_dem_path do |stdin|
|
33
|
+
stdin.puts cutline.to_json
|
34
|
+
end
|
33
35
|
indexed_dem_path
|
34
36
|
end.tap do |dem_paths|
|
35
37
|
txt_path.write dem_paths.join(?\n)
|
@@ -39,26 +41,25 @@ module NSWTopo
|
|
39
41
|
end
|
40
42
|
|
41
43
|
def blur_dem(dem_path, blur_path)
|
42
|
-
|
43
|
-
half = (3 * sigma / @resolution).ceil
|
44
|
+
half = (3 * @smooth / @mm_per_px).ceil
|
44
45
|
|
45
46
|
coeffs = (-half..half).map do |n|
|
46
|
-
n * @
|
47
|
+
n * @mm_per_px / @smooth
|
47
48
|
end.map do |x|
|
48
49
|
Math::exp(-x**2)
|
49
50
|
end
|
50
51
|
|
51
52
|
vrt = OS.gdalbuildvrt "/vsistdout/", dem_path
|
52
53
|
xml = REXML::Document.new vrt
|
53
|
-
xml.elements.each("//ComplexSource") do |
|
54
|
-
kernel_filtered_source =
|
55
|
-
|
54
|
+
xml.elements.each("//ComplexSource|//SimpleSource") do |source|
|
55
|
+
kernel_filtered_source = source.parent.add_element("KernelFilteredSource")
|
56
|
+
source.elements.each("SourceFilename|OpenOptions|SourceBand|SourceProperties|SrcRect|DstRect") do |element|
|
56
57
|
kernel_filtered_source.add_element element
|
57
58
|
end
|
58
59
|
kernel = kernel_filtered_source.add_element("Kernel", "normalized" => 1)
|
59
60
|
kernel.add_element("Size").text = coeffs.size
|
60
61
|
kernel.add_element("Coefs").text = coeffs.join ?\s
|
61
|
-
|
62
|
+
source.parent.delete source
|
62
63
|
end
|
63
64
|
|
64
65
|
log_update "%s: smoothing DEM raster" % @name
|
data/lib/nswtopo/gis/esri_hdr.rb
CHANGED
@@ -40,9 +40,15 @@ module NSWTopo
|
|
40
40
|
|
41
41
|
@values = case path_or_object
|
42
42
|
when Pathname
|
43
|
-
|
44
|
-
|
43
|
+
data = []
|
44
|
+
path_or_object.sub_ext(".bil").open("rb") do |file|
|
45
|
+
while !file.eof?
|
46
|
+
data += file.read(32*1024*1024).unpack(@format).map do |value|
|
47
|
+
value == @nodata ? nil : value
|
48
|
+
end
|
49
|
+
end
|
45
50
|
end
|
51
|
+
data
|
46
52
|
when ESRIHdr then args[0]
|
47
53
|
end
|
48
54
|
end
|
@@ -1,22 +1,27 @@
|
|
1
1
|
module NSWTopo
|
2
2
|
module GeoJSON
|
3
|
+
DEFAULT_PROJECTION = Projection.wgs84
|
4
|
+
|
3
5
|
class Collection
|
4
|
-
def initialize(projection
|
5
|
-
@projection, @features = projection, features
|
6
|
+
def initialize(projection: DEFAULT_PROJECTION, features: [], name: nil)
|
7
|
+
@projection, @features, @name = projection, features, name
|
6
8
|
end
|
7
|
-
attr_reader :projection, :features
|
9
|
+
attr_reader :projection, :features, :name
|
8
10
|
|
9
|
-
def self.load(json, projection
|
11
|
+
def self.load(json, projection: nil, name: nil)
|
10
12
|
collection = JSON.parse(json)
|
11
|
-
|
12
|
-
projection ||=
|
13
|
-
collection["
|
13
|
+
crs_name = collection.dig "crs", "properties", "name"
|
14
|
+
projection ||= crs_name ? Projection.new(crs_name) : DEFAULT_PROJECTION
|
15
|
+
name ||= collection["name"]
|
16
|
+
collection["features"].select do |feature|
|
17
|
+
feature["geometry"]
|
18
|
+
end.map do |feature|
|
14
19
|
geometry, properties = feature.values_at "geometry", "properties"
|
15
20
|
type, coordinates = geometry.values_at "type", "coordinates"
|
16
21
|
raise Error, "unsupported geometry type: #{type}" unless TYPES === type
|
17
22
|
GeoJSON.const_get(type).new coordinates, properties
|
18
|
-
end.
|
19
|
-
new projection, features
|
23
|
+
end.then do |features|
|
24
|
+
new projection: projection, features: features, name: name
|
20
25
|
end
|
21
26
|
rescue JSON::ParserError
|
22
27
|
raise Error, "invalid GeoJSON data"
|
@@ -37,7 +42,7 @@ module NSWTopo
|
|
37
42
|
json = OS.ogr2ogr "-t_srs", projection, "-f", "GeoJSON", "-lco", "RFC7946=NO", "/vsistdout/", "GeoJSON:/vsistdin/" do |stdin|
|
38
43
|
stdin.puts to_json
|
39
44
|
end
|
40
|
-
Collection.load json, projection
|
45
|
+
Collection.load json, projection: projection
|
41
46
|
end
|
42
47
|
|
43
48
|
def reproject_to_wgs84
|
@@ -49,28 +54,30 @@ module NSWTopo
|
|
49
54
|
"type" => "FeatureCollection",
|
50
55
|
"crs" => { "type" => "name", "properties" => { "name" => @projection } },
|
51
56
|
"features" => map(&:to_h)
|
52
|
-
}
|
57
|
+
}.tap do |hash|
|
58
|
+
hash["name"] = @name if @name
|
59
|
+
end
|
53
60
|
end
|
54
61
|
|
55
62
|
extend Forwardable
|
56
|
-
delegate %i[coordinates properties] => :first
|
57
|
-
delegate %i[reject! select!] => :@features
|
63
|
+
delegate %i[coordinates properties wkt area] => :first
|
64
|
+
delegate %i[reject! select! length] => :@features
|
58
65
|
|
59
66
|
def to_json(**extras)
|
60
67
|
to_h.merge(extras).to_json
|
61
68
|
end
|
62
69
|
|
63
70
|
def explode
|
64
|
-
Collection.new @projection, flat_map(&:explode)
|
71
|
+
Collection.new projection: @projection, name: @name, features: flat_map(&:explode)
|
65
72
|
end
|
66
73
|
|
67
74
|
def multi
|
68
|
-
Collection.new @projection, map(&:multi)
|
75
|
+
Collection.new projection: @projection, name: @name, features: map(&:multi)
|
69
76
|
end
|
70
77
|
|
71
78
|
def merge(other)
|
72
79
|
raise Error, "can't merge different projections" unless @projection == other.projection
|
73
|
-
Collection.new @projection, @features + other.features
|
80
|
+
Collection.new projection: @projection, name: @name, features: @features + other.features
|
74
81
|
end
|
75
82
|
|
76
83
|
def merge!(other)
|
@@ -78,17 +85,34 @@ module NSWTopo
|
|
78
85
|
tap { @features.concat other.features }
|
79
86
|
end
|
80
87
|
|
81
|
-
def clip
|
82
|
-
|
83
|
-
|
84
|
-
end.
|
85
|
-
|
88
|
+
def clip(polygon)
|
89
|
+
OS.ogr2ogr "-f", "GeoJSON", "-lco", "RFC7946=NO", "-clipsrc", polygon.wkt, "/vsistdout/", "GeoJSON:/vsistdin/" do |stdin|
|
90
|
+
stdin.puts to_json
|
91
|
+
end.then do |json|
|
92
|
+
Collection.load json, projection: @projection
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def buffer(*margins, **options)
|
97
|
+
map do |feature|
|
98
|
+
feature.buffer(*margins, **options)
|
99
|
+
end.then do |features|
|
100
|
+
Collection.new projection: @projection, name: @name, features: features
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def rename(name = nil)
|
105
|
+
tap { @name = name }
|
86
106
|
end
|
87
107
|
|
88
108
|
# TODO: what about empty collections?
|
89
109
|
def bounds
|
90
110
|
map(&:bounds).transpose.map(&:flatten).map(&:minmax)
|
91
111
|
end
|
112
|
+
|
113
|
+
def bbox
|
114
|
+
GeoJSON.polygon [bounds.inject(&:product).values_at(0,2,3,1,0)], projection: @projection
|
115
|
+
end
|
92
116
|
end
|
93
117
|
end
|
94
118
|
end
|
@@ -3,28 +3,6 @@ module NSWTopo
|
|
3
3
|
class MultiLineString
|
4
4
|
include StraightSkeleton
|
5
5
|
|
6
|
-
def clip(hull)
|
7
|
-
lines = [hull, hull.perps].transpose.inject(@coordinates) do |result, (vertex, perp)|
|
8
|
-
result.inject([]) do |clipped, points|
|
9
|
-
clipped + [*points, points.last].segments.inject([[]]) do |lines, segment|
|
10
|
-
inside = segment.map { |point| point.minus(vertex).dot(perp) >= 0 }
|
11
|
-
case
|
12
|
-
when inside.all?
|
13
|
-
lines.last << segment[0]
|
14
|
-
when inside[0]
|
15
|
-
lines.last << segment[0]
|
16
|
-
lines.last << segment.along(vertex.minus(segment[0]).dot(perp) / segment.difference.dot(perp))
|
17
|
-
when inside[1]
|
18
|
-
lines << []
|
19
|
-
lines.last << segment.along(vertex.minus(segment[0]).dot(perp) / segment.difference.dot(perp))
|
20
|
-
end
|
21
|
-
lines
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end.select(&:many?)
|
25
|
-
lines.none? ? nil : lines.one? ? LineString.new(*lines, @properties) : MultiLineString.new(lines, @properties)
|
26
|
-
end
|
27
|
-
|
28
6
|
def length
|
29
7
|
@coordinates.sum(&:path_length)
|
30
8
|
end
|
@@ -50,12 +28,12 @@ module NSWTopo
|
|
50
28
|
end
|
51
29
|
|
52
30
|
def samples(interval)
|
53
|
-
points = @coordinates.
|
31
|
+
points = @coordinates.flat_map do |linestring|
|
54
32
|
distance = linestring.path_length
|
55
33
|
linestring.sample_at(interval, along: true).map do |point, along|
|
56
34
|
[point, (2 * along - distance).abs - distance]
|
57
35
|
end
|
58
|
-
end.
|
36
|
+
end.sort_by(&:last).map(&:first)
|
59
37
|
MultiPoint.new points, @properties
|
60
38
|
end
|
61
39
|
end
|
@@ -3,57 +3,6 @@ module NSWTopo
|
|
3
3
|
class MultiPolygon
|
4
4
|
include StraightSkeleton
|
5
5
|
|
6
|
-
def clip(hull)
|
7
|
-
polys = @coordinates.inject([]) do |result, rings|
|
8
|
-
lefthanded = rings.first.clockwise?
|
9
|
-
interior, exterior = hull.zip(hull.perps).inject(rings) do |rings, (vertex, perp)|
|
10
|
-
insides, neighbours, clipped = Hash[].compare_by_identity, Hash[].compare_by_identity, []
|
11
|
-
rings.each do |points|
|
12
|
-
points.map do |point|
|
13
|
-
point.minus(vertex).dot(perp) >= 0
|
14
|
-
end.segments.zip(points.segments).each do |inside, segment|
|
15
|
-
insides[segment] = inside
|
16
|
-
neighbours[segment] = [nil, nil]
|
17
|
-
end.map(&:last).ring.each do |segment0, segment1|
|
18
|
-
neighbours[segment1][0], neighbours[segment0][1] = segment0, segment1
|
19
|
-
end
|
20
|
-
end
|
21
|
-
neighbours.select! do |segment, _|
|
22
|
-
insides[segment].any?
|
23
|
-
end
|
24
|
-
insides.select do |segment, inside|
|
25
|
-
inside.inject(&:^)
|
26
|
-
end.each do |segment, inside|
|
27
|
-
segment[inside[0] ? 1 : 0] = segment.along(vertex.minus(segment[0]).dot(perp) / segment.difference.dot(perp))
|
28
|
-
end.sort_by do |segment, inside|
|
29
|
-
segment[inside[0] ? 1 : 0].minus(vertex).cross(perp) * (lefthanded ? -1 : 1)
|
30
|
-
end.map(&:first).each_slice(2) do |segment0, segment1|
|
31
|
-
segment = [segment0[1], segment1[0]]
|
32
|
-
neighbours[segment0][1] = neighbours[segment1][0] = segment
|
33
|
-
neighbours[segment] = [segment0, segment1]
|
34
|
-
end
|
35
|
-
while neighbours.any?
|
36
|
-
segment, * = neighbours.first
|
37
|
-
clipped << []
|
38
|
-
while neighbours.include? segment
|
39
|
-
clipped.last << segment[0]
|
40
|
-
*, segment = neighbours.delete(segment)
|
41
|
-
end
|
42
|
-
clipped.last << clipped.last.first
|
43
|
-
end
|
44
|
-
clipped
|
45
|
-
end.partition(&:clockwise?).rotate(lefthanded ? 1 : 0)
|
46
|
-
next result << exterior + interior if exterior.one?
|
47
|
-
exterior.inject(result) do |result, exterior_ring|
|
48
|
-
within, interior = interior.partition do |interior_ring|
|
49
|
-
interior_ring.first.within? exterior_ring
|
50
|
-
end
|
51
|
-
result << [exterior_ring, *within]
|
52
|
-
end
|
53
|
-
end
|
54
|
-
polys.none? ? nil : polys.one? ? Polygon.new(*polys, @properties) : MultiPolygon.new(polys, @properties)
|
55
|
-
end
|
56
|
-
|
57
6
|
def area
|
58
7
|
@coordinates.flatten(1).sum(&:signed_area)
|
59
8
|
end
|
@@ -119,13 +68,13 @@ module NSWTopo
|
|
119
68
|
lengths[index], lines[index] = length + tail_length, nodes + tail_nodes.reverse if length + tail_length > lengths[index]
|
120
69
|
end
|
121
70
|
|
122
|
-
linestrings = lines.values.
|
71
|
+
linestrings = lines.values.flat_map do |nodes|
|
123
72
|
nodes.chunk do |node|
|
124
73
|
node.travel >= min_travel
|
125
74
|
end.select(&:first).map(&:last).reject(&:one?).map do |nodes|
|
126
75
|
nodes.map(&:point).to_f
|
127
76
|
end
|
128
|
-
end
|
77
|
+
end
|
129
78
|
features.prepend MultiLineString.new(linestrings, @properties)
|
130
79
|
end
|
131
80
|
|
@@ -3,9 +3,24 @@ module NSWTopo
|
|
3
3
|
class Polygon
|
4
4
|
delegate %i[area skeleton centres centrepoints centrelines buffer centroids samples] => :multi
|
5
5
|
|
6
|
+
def validate!
|
7
|
+
@coordinates.inject(false) do |hole, ring|
|
8
|
+
ring.reverse! if hole ^ ring.hole?
|
9
|
+
true
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
6
13
|
def bounds
|
7
14
|
@coordinates.first.transpose.map(&:minmax)
|
8
15
|
end
|
16
|
+
|
17
|
+
def wkt
|
18
|
+
@coordinates.map do |ring|
|
19
|
+
ring.map do |point|
|
20
|
+
point.join(" ")
|
21
|
+
end.join(", ").prepend("(").concat(")")
|
22
|
+
end.join(", ").prepend("POLYGON (").concat(")")
|
23
|
+
end
|
9
24
|
end
|
10
25
|
end
|
11
26
|
end
|
data/lib/nswtopo/gis/geojson.rb
CHANGED
@@ -12,6 +12,7 @@ module NSWTopo
|
|
12
12
|
raise Error, "invalid feature properties" unless Hash === properties
|
13
13
|
raise Error, "invalid feature geometry" unless Array === coordinates
|
14
14
|
@coordinates, @properties = coordinates, properties
|
15
|
+
validate!
|
15
16
|
end
|
16
17
|
attr_accessor :coordinates, :properties
|
17
18
|
|
@@ -42,8 +43,12 @@ module NSWTopo
|
|
42
43
|
grep klass
|
43
44
|
end
|
44
45
|
|
45
|
-
|
46
|
-
|
46
|
+
Collection.define_method "#{type}?".downcase do
|
47
|
+
one? && klass === first
|
48
|
+
end
|
49
|
+
|
50
|
+
define_singleton_method type.downcase do |coordinates, projection: DEFAULT_PROJECTION, name: nil, properties: {}|
|
51
|
+
Collection.new(projection: projection, name: name) << klass.new(coordinates, properties)
|
47
52
|
end
|
48
53
|
end
|
49
54
|
|
@@ -60,6 +65,7 @@ module NSWTopo
|
|
60
65
|
end
|
61
66
|
|
62
67
|
delegate :clip => :multi
|
68
|
+
alias validate! itself
|
63
69
|
end
|
64
70
|
|
65
71
|
multi_class.class_eval do
|
@@ -69,6 +75,10 @@ module NSWTopo
|
|
69
75
|
end
|
70
76
|
end
|
71
77
|
|
78
|
+
def validate!
|
79
|
+
explode.each &:validate!
|
80
|
+
end
|
81
|
+
|
72
82
|
def bounds
|
73
83
|
explode.map(&:bounds).transpose.map(&:flatten).map(&:minmax)
|
74
84
|
end
|
@@ -84,6 +94,5 @@ end
|
|
84
94
|
require_relative 'geojson/point'
|
85
95
|
require_relative 'geojson/line_string'
|
86
96
|
require_relative 'geojson/polygon'
|
87
|
-
require_relative 'geojson/multi_point'
|
88
97
|
require_relative 'geojson/multi_line_string'
|
89
98
|
require_relative 'geojson/multi_polygon'
|