nswtopo 2.0.0.pre.beta1 → 3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/COPYING +70 -83
  3. data/bin/nswtopo +227 -116
  4. data/docs/README.md +1 -12
  5. data/docs/add.md +1 -1
  6. data/docs/config.md +1 -1
  7. data/docs/contours.md +3 -1
  8. data/docs/init.md +8 -0
  9. data/docs/inspect.md +103 -0
  10. data/docs/move.md +9 -0
  11. data/docs/render.md +16 -7
  12. data/docs/scrape.md +67 -0
  13. data/docs/spot-heights.md +6 -2
  14. data/lib/nswtopo/archive.rb +50 -41
  15. data/lib/nswtopo/chrome.rb +227 -0
  16. data/lib/nswtopo/commands/add.rb +106 -0
  17. data/lib/nswtopo/commands/config.rb +38 -0
  18. data/lib/nswtopo/commands/inspect.rb +74 -0
  19. data/lib/nswtopo/commands/layers.rb +22 -0
  20. data/lib/nswtopo/commands/scrape.rb +79 -0
  21. data/lib/nswtopo/commands.rb +57 -0
  22. data/lib/nswtopo/dither.rb +5 -3
  23. data/lib/nswtopo/font.rb +46 -21
  24. data/lib/nswtopo/formats/gemf.rb +42 -0
  25. data/lib/nswtopo/formats/kmz.rb +26 -24
  26. data/lib/nswtopo/formats/mbtiles.rb +5 -41
  27. data/lib/nswtopo/formats/pdf.rb +82 -17
  28. data/lib/nswtopo/formats/svg.rb +114 -45
  29. data/lib/nswtopo/formats/svgz.rb +2 -2
  30. data/lib/nswtopo/formats/zip.rb +33 -23
  31. data/lib/nswtopo/formats.rb +77 -32
  32. data/lib/nswtopo/geometry/overlap.rb +1 -32
  33. data/lib/nswtopo/geometry/r_tree.rb +16 -10
  34. data/lib/nswtopo/geometry/segment.rb +3 -3
  35. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +5 -6
  36. data/lib/nswtopo/geometry/vector_sequence.rb +7 -6
  37. data/lib/nswtopo/gis/arcgis/connection.rb +56 -0
  38. data/lib/nswtopo/gis/arcgis/layer/map.rb +163 -0
  39. data/lib/nswtopo/gis/arcgis/layer/query.rb +87 -0
  40. data/lib/nswtopo/gis/arcgis/layer/renderer.rb +66 -0
  41. data/lib/nswtopo/gis/arcgis/layer/statistics.rb +15 -0
  42. data/lib/nswtopo/gis/arcgis/layer.rb +201 -0
  43. data/lib/nswtopo/gis/arcgis/service.rb +57 -0
  44. data/lib/nswtopo/gis/arcgis.rb +3 -0
  45. data/lib/nswtopo/gis/dem.rb +13 -12
  46. data/lib/nswtopo/gis/esri_hdr.rb +8 -2
  47. data/lib/nswtopo/gis/geojson/collection.rb +45 -21
  48. data/lib/nswtopo/gis/geojson/multi_line_string.rb +2 -24
  49. data/lib/nswtopo/gis/geojson/multi_polygon.rb +2 -53
  50. data/lib/nswtopo/gis/geojson/polygon.rb +15 -0
  51. data/lib/nswtopo/gis/geojson.rb +12 -3
  52. data/lib/nswtopo/gis/gps/kml.rb +25 -19
  53. data/lib/nswtopo/gis/gps.rb +2 -0
  54. data/lib/nswtopo/gis/projection.rb +35 -24
  55. data/lib/nswtopo/gis/shapefile.rb +89 -16
  56. data/lib/nswtopo/gis.rb +1 -2
  57. data/lib/nswtopo/helpers/array.rb +0 -11
  58. data/lib/nswtopo/helpers/colour.rb +34 -14
  59. data/lib/nswtopo/layer/arcgis_raster.rb +44 -48
  60. data/lib/nswtopo/layer/colour_mask.rb +5 -0
  61. data/lib/nswtopo/layer/contour.rb +35 -28
  62. data/lib/nswtopo/layer/control.rb +2 -7
  63. data/lib/nswtopo/layer/declination.rb +9 -9
  64. data/lib/nswtopo/layer/feature.rb +36 -22
  65. data/lib/nswtopo/layer/grid.rb +30 -27
  66. data/lib/nswtopo/layer/import.rb +1 -21
  67. data/lib/nswtopo/layer/labels/barrier.rb +39 -0
  68. data/lib/nswtopo/layer/labels.rb +551 -383
  69. data/lib/nswtopo/layer/mask_render.rb +37 -0
  70. data/lib/nswtopo/layer/overlay.rb +2 -2
  71. data/lib/nswtopo/layer/raster.rb +31 -41
  72. data/lib/nswtopo/layer/raster_import.rb +17 -0
  73. data/lib/nswtopo/layer/raster_render.rb +15 -0
  74. data/lib/nswtopo/layer/relief.rb +27 -95
  75. data/lib/nswtopo/layer/spot.rb +63 -62
  76. data/lib/nswtopo/layer/vector/cutout.rb +15 -0
  77. data/lib/nswtopo/layer/vector/knockout.rb +16 -0
  78. data/lib/nswtopo/layer/vector.rb +121 -89
  79. data/lib/nswtopo/layer/vegetation.rb +39 -34
  80. data/lib/nswtopo/layer.rb +30 -16
  81. data/lib/nswtopo/map.rb +202 -109
  82. data/lib/nswtopo/os.rb +5 -27
  83. data/lib/nswtopo/tiled_web_map.rb +54 -0
  84. data/lib/nswtopo/tree_indenter.rb +27 -0
  85. data/lib/nswtopo/version.rb +27 -2
  86. data/lib/nswtopo.rb +6 -199
  87. metadata +41 -22
  88. data/lib/nswtopo/font/chrome.rb +0 -59
  89. data/lib/nswtopo/font/generic.rb +0 -25
  90. data/lib/nswtopo/gis/arcgis_server/connection.rb +0 -52
  91. data/lib/nswtopo/gis/arcgis_server.rb +0 -155
  92. data/lib/nswtopo/gis/geojson/multi_point.rb +0 -12
  93. data/lib/nswtopo/gis/world_file.rb +0 -19
  94. 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
@@ -0,0 +1,3 @@
1
+ require_relative 'arcgis/connection'
2
+ require_relative 'arcgis/service'
3
+ require_relative 'arcgis/layer'
@@ -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
- te = @map.bounds(margin: margin).transpose.flatten
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.proj4.split(?\s).any?("+units=m")
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
- @resolution ||= resolutions.first
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, "-te", *te, "-tr", @resolution, @resolution, "-r", "bilinear", vrt_path, indexed_dem_path
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
- sigma = @smooth * @map.scale / 1000.0
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 * @resolution / sigma
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 |complex_source|
54
- kernel_filtered_source = complex_source.parent.add_element("KernelFilteredSource")
55
- complex_source.elements.each("SourceFilename|OpenOptions|SourceBand|SourceProperties|SrcRect|DstRect") do |element|
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
- complex_source.parent.delete complex_source
62
+ source.parent.delete source
62
63
  end
63
64
 
64
65
  log_update "%s: smoothing DEM raster" % @name
@@ -40,9 +40,15 @@ module NSWTopo
40
40
 
41
41
  @values = case path_or_object
42
42
  when Pathname
43
- path_or_object.sub_ext(".bil").binread.unpack(@format).map do |value|
44
- value == @nodata ? nil : value
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 = Projection.wgs84, features = [])
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 = nil)
11
+ def self.load(json, projection: nil, name: nil)
10
12
  collection = JSON.parse(json)
11
- proj4 = collection.dig "crs", "properties", "name"
12
- projection ||= proj4 ? Projection.new(proj4) : Projection.wgs84
13
- collection["features"].map do |feature|
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.yield_self do |features|
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!(hull)
82
- @features.map! do |feature|
83
- feature.clip hull
84
- end.compact!
85
- self
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.map do |linestring|
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.flatten(1).sort_by(&:last).map(&:first)
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.map do |nodes|
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.flatten(1)
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
@@ -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
- define_singleton_method type.downcase do |coordinates, projection: nil, properties: {}|
46
- Collection.new(*projection) << klass.new(coordinates, properties)
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'