nswtopo 2.0.0.pre.beta1 → 3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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'