nswtopo 2.0.0.pre.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/COPYING +674 -0
  3. data/bin/nswtopo +430 -0
  4. data/docs/README.md +78 -0
  5. data/docs/add.md +49 -0
  6. data/docs/config.md +24 -0
  7. data/docs/contours.md +37 -0
  8. data/docs/controls.md +9 -0
  9. data/docs/declination.md +15 -0
  10. data/docs/delete.md +15 -0
  11. data/docs/grid.md +5 -0
  12. data/docs/info.md +5 -0
  13. data/docs/init.md +38 -0
  14. data/docs/layers.md +11 -0
  15. data/docs/overlay.md +37 -0
  16. data/docs/relief.md +22 -0
  17. data/docs/render.md +43 -0
  18. data/docs/spot-heights.md +23 -0
  19. data/lib/nswtopo/archive.rb +93 -0
  20. data/lib/nswtopo/avl_tree.rb +128 -0
  21. data/lib/nswtopo/config.rb +73 -0
  22. data/lib/nswtopo/dither.rb +31 -0
  23. data/lib/nswtopo/font/chrome.rb +59 -0
  24. data/lib/nswtopo/font/generic.rb +25 -0
  25. data/lib/nswtopo/font.rb +43 -0
  26. data/lib/nswtopo/formats/kmz.rb +149 -0
  27. data/lib/nswtopo/formats/mbtiles.rb +64 -0
  28. data/lib/nswtopo/formats/pdf.rb +31 -0
  29. data/lib/nswtopo/formats/svg.rb +69 -0
  30. data/lib/nswtopo/formats/svgz.rb +13 -0
  31. data/lib/nswtopo/formats/zip.rb +40 -0
  32. data/lib/nswtopo/formats.rb +76 -0
  33. data/lib/nswtopo/geometry/overlap.rb +78 -0
  34. data/lib/nswtopo/geometry/r_tree.rb +47 -0
  35. data/lib/nswtopo/geometry/segment.rb +27 -0
  36. data/lib/nswtopo/geometry/straight_skeleton/collapse.rb +21 -0
  37. data/lib/nswtopo/geometry/straight_skeleton/interior_node.rb +17 -0
  38. data/lib/nswtopo/geometry/straight_skeleton/node.rb +50 -0
  39. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +295 -0
  40. data/lib/nswtopo/geometry/straight_skeleton/split.rb +33 -0
  41. data/lib/nswtopo/geometry/straight_skeleton/vertex.rb +9 -0
  42. data/lib/nswtopo/geometry/straight_skeleton.rb +6 -0
  43. data/lib/nswtopo/geometry/vector.rb +91 -0
  44. data/lib/nswtopo/geometry/vector_sequence.rb +179 -0
  45. data/lib/nswtopo/geometry.rb +8 -0
  46. data/lib/nswtopo/gis/arcgis_server/connection.rb +52 -0
  47. data/lib/nswtopo/gis/arcgis_server.rb +155 -0
  48. data/lib/nswtopo/gis/dem.rb +70 -0
  49. data/lib/nswtopo/gis/esri_hdr.rb +77 -0
  50. data/lib/nswtopo/gis/gdal_glob.rb +41 -0
  51. data/lib/nswtopo/gis/geojson/collection.rb +94 -0
  52. data/lib/nswtopo/gis/geojson/line_string.rb +11 -0
  53. data/lib/nswtopo/gis/geojson/multi_line_string.rb +63 -0
  54. data/lib/nswtopo/gis/geojson/multi_point.rb +12 -0
  55. data/lib/nswtopo/gis/geojson/multi_polygon.rb +167 -0
  56. data/lib/nswtopo/gis/geojson/point.rb +9 -0
  57. data/lib/nswtopo/gis/geojson/polygon.rb +11 -0
  58. data/lib/nswtopo/gis/geojson.rb +89 -0
  59. data/lib/nswtopo/gis/gps/gpx.rb +22 -0
  60. data/lib/nswtopo/gis/gps/kml.rb +66 -0
  61. data/lib/nswtopo/gis/gps.rb +20 -0
  62. data/lib/nswtopo/gis/projection.rb +56 -0
  63. data/lib/nswtopo/gis/shapefile.rb +24 -0
  64. data/lib/nswtopo/gis/world_file.rb +19 -0
  65. data/lib/nswtopo/gis.rb +9 -0
  66. data/lib/nswtopo/help_formatter.rb +59 -0
  67. data/lib/nswtopo/helpers/array.rb +30 -0
  68. data/lib/nswtopo/helpers/colour.rb +176 -0
  69. data/lib/nswtopo/helpers/concurrently.rb +27 -0
  70. data/lib/nswtopo/helpers/dir.rb +7 -0
  71. data/lib/nswtopo/helpers/hash.rb +15 -0
  72. data/lib/nswtopo/helpers/tar_writer.rb +11 -0
  73. data/lib/nswtopo/helpers.rb +6 -0
  74. data/lib/nswtopo/layer/arcgis_raster.rb +73 -0
  75. data/lib/nswtopo/layer/contour.rb +233 -0
  76. data/lib/nswtopo/layer/control.rb +94 -0
  77. data/lib/nswtopo/layer/declination.rb +53 -0
  78. data/lib/nswtopo/layer/feature.rb +87 -0
  79. data/lib/nswtopo/layer/grid.rb +120 -0
  80. data/lib/nswtopo/layer/import.rb +25 -0
  81. data/lib/nswtopo/layer/labels/fence.rb +20 -0
  82. data/lib/nswtopo/layer/labels.rb +630 -0
  83. data/lib/nswtopo/layer/overlay.rb +53 -0
  84. data/lib/nswtopo/layer/raster.rb +63 -0
  85. data/lib/nswtopo/layer/relief.rb +143 -0
  86. data/lib/nswtopo/layer/spot.rb +171 -0
  87. data/lib/nswtopo/layer/vector.rb +263 -0
  88. data/lib/nswtopo/layer/vegetation.rb +73 -0
  89. data/lib/nswtopo/layer.rb +78 -0
  90. data/lib/nswtopo/log.rb +28 -0
  91. data/lib/nswtopo/map.rb +296 -0
  92. data/lib/nswtopo/os.rb +75 -0
  93. data/lib/nswtopo/safely.rb +13 -0
  94. data/lib/nswtopo/version.rb +4 -0
  95. data/lib/nswtopo/zip.rb +15 -0
  96. data/lib/nswtopo.rb +249 -0
  97. metadata +142 -0
@@ -0,0 +1,52 @@
1
+ module NSWTopo
2
+ module ArcGISServer
3
+ class Connection
4
+ def initialize(http, service_path)
5
+ @http, @service_path, @headers = http, service_path, { "User-Agent" => "Ruby/#{RUBY_VERSION}", "Referer" => "%s://%s" % [http.use_ssl? ? "https" : "http", http.address] }
6
+ http.max_retries = 0
7
+ end
8
+
9
+ def repeatedly_request(request)
10
+ intervals ||= 5.times.map(&1.4142.method(:**))
11
+ response = @http.request(request)
12
+ response.error! unless Net::HTTPSuccess === response
13
+ yield response
14
+ rescue *ERRORS, Error => error
15
+ interval = intervals.shift
16
+ interval ? sleep(interval) : raise(error)
17
+ retry
18
+ end
19
+
20
+ def get(relative_path, **query, &block)
21
+ path = Pathname(@service_path).join(relative_path).to_s
22
+ path << ?? << URI.encode_www_form(query) unless query.empty?
23
+ request = Net::HTTP::Get.new(path, @headers)
24
+ repeatedly_request(request, &block)
25
+ end
26
+
27
+ def post(relative_path, **query, &block)
28
+ path = Pathname(@service_path).join(relative_path).to_s
29
+ request = Net::HTTP::Post.new(path, @headers)
30
+ request.body = URI.encode_www_form(query)
31
+ repeatedly_request(request, &block)
32
+ end
33
+
34
+ def process_json(response)
35
+ JSON.parse(response.body).tap do |result|
36
+ # raise Error, result["error"].values_at("message", "details").compact.join(?\n) if result["error"]
37
+ raise Error, result["error"]["message"] if result["error"]
38
+ end
39
+ rescue JSON::ParserError
40
+ raise Error, "unexpected ArcGIS response format"
41
+ end
42
+
43
+ def get_json(relative_path = "", **query)
44
+ get relative_path, query.merge(f: "json"), &method(:process_json)
45
+ end
46
+
47
+ def post_json(relative_path = "", **query)
48
+ post relative_path, query.merge(f: "json"), &method(:process_json)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,155 @@
1
+ require_relative 'arcgis_server/connection'
2
+
3
+ module NSWTopo
4
+ module ArcGISServer
5
+ Error = Class.new RuntimeError
6
+ ERRORS = [Timeout::Error, Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError]
7
+ SERVICE = /^(?:MapServer|FeatureServer|ImageServer)$/
8
+
9
+ def self.check_uri(url)
10
+ uri = URI.parse url
11
+ return unless URI::HTTP === uri
12
+ instance, (id, *) = uri.path.split(?/).slice_after(SERVICE).take(2)
13
+ return unless instance.last =~ SERVICE
14
+ return unless !id || id =~ /^\d+$/
15
+ return uri, instance.join(?/), id
16
+ rescue URI::Error
17
+ end
18
+
19
+ def self.===(string)
20
+ uri, service_path, id = check_uri string
21
+ uri != nil
22
+ end
23
+
24
+ def self.start(url, &block)
25
+ uri, service_path, id = check_uri url
26
+ raise "invalid ArcGIS server URL: %s" % url unless uri
27
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 600) do |http|
28
+ connection = Connection.new http, service_path
29
+ service = connection.get_json
30
+ projection = case
31
+ when wkt = service.dig("spatialReference", "wkt") then Projection.new(wkt)
32
+ when wkid = service.dig("spatialReference", "latestWkid") then Projection.new("EPSG:#{wkid}")
33
+ when wkid = service.dig("spatialReference", "wkid") then Projection.new("EPSG:#{wkid == 102100 ? 3857 : wkid}")
34
+ else raise Error, "no spatial reference found: #{uri}"
35
+ end
36
+ yield connection, service, projection, *id
37
+ end
38
+ rescue *ERRORS => error
39
+ raise Error, error.message
40
+ end
41
+
42
+ def arcgis_layer(url, where: nil, layer: nil, per_page: nil, margin: {})
43
+ ArcGISServer.start url do |connection, service, projection, id|
44
+ id = service["layers"].find do |info|
45
+ layer.to_s == info["name"]
46
+ end&.dig("id") if layer
47
+ id ? nil : layer ? raise("no such ArcGIS layer: %s" % layer) : raise("not an ArcGIS layer url: %s" % url)
48
+
49
+ layer = connection.get_json id.to_s
50
+ query_path = "#{id}/query"
51
+ max_record_count, fields, types, type_id_field, geometry_type, capabilities = layer.values_at "maxRecordCount", "fields", "types", "typeIdField", "geometryType", "capabilities"
52
+ raise Error, "no query capability available: #{url}" unless capabilities =~ /Query|Data/
53
+
54
+ if type_id_field && types
55
+ type_id_field = fields.find do |field|
56
+ field.values_at("alias", "name").include? type_id_field
57
+ end&.fetch("name")
58
+ type_values = types.map do |type|
59
+ type.values_at "id", "name"
60
+ end.to_h
61
+ subtype_coded_values = types.map do |type|
62
+ type.values_at "id", "domains"
63
+ end.map do |id, domains|
64
+ coded_values = domains.map do |name, domain|
65
+ [name, domain["codedValues"]]
66
+ end.select(&:last).map do |name, pairs|
67
+ values = pairs.map do |pair|
68
+ pair.values_at "code", "name"
69
+ end.to_h
70
+ [name, values]
71
+ end.to_h
72
+ [id, coded_values]
73
+ end.to_h
74
+ end
75
+
76
+ coded_values = fields.map do |field|
77
+ [field["name"], field.dig("domain", "codedValues")]
78
+ end.select(&:last).map do |name, pairs|
79
+ values = pairs.map do |pair|
80
+ pair.values_at "code", "name"
81
+ end.to_h
82
+ [name, values]
83
+ end.to_h
84
+
85
+ geometry = { rings: @map.bounding_box(margin).reproject_to(projection).coordinates.map(&:reverse) }.to_json
86
+ where = Array(where).map { |clause| "(#{clause})"}.join " AND "
87
+ query = { geometry: geometry, geometryType: "esriGeometryPolygon", returnIdsOnly: true, where: where }
88
+
89
+ object_ids = connection.get_json(query_path, query)["objectIds"]
90
+ next GeoJSON::Collection.new projection unless object_ids
91
+
92
+ features = Enumerator.new do |yielder|
93
+ per_page, total = [*per_page, *max_record_count, 500].min, object_ids.length
94
+ while object_ids.any?
95
+ yield total - object_ids.length, total if block_given? && total > 0
96
+ yielder << begin
97
+ connection.get_json query_path, outFields: ?*, objectIds: object_ids.take(per_page).join(?,)
98
+ rescue Error => error
99
+ (per_page /= 2) > 0 ? retry : raise(error)
100
+ end
101
+ object_ids.shift per_page
102
+ end
103
+ end.inject [] do |features, page|
104
+ features += page["features"]
105
+ end.map do |feature|
106
+ next unless geometry = feature["geometry"]
107
+ attributes = feature.fetch "attributes", {}
108
+
109
+ values = attributes.map do |name, value|
110
+ case
111
+ when type_id_field == name
112
+ type_values[value]
113
+ when decode = subtype_coded_values&.dig(attributes[type_id_field], name)
114
+ decode[value]
115
+ when decode = coded_values.dig(name)
116
+ decode[value]
117
+ when %w[null Null NULL <null> <Null> <NULL>].include?(value)
118
+ nil
119
+ else value
120
+ end
121
+ end
122
+ attributes = attributes.keys.zip(values).to_h
123
+
124
+ case geometry_type
125
+ when "esriGeometryPoint"
126
+ point = geometry.values_at "x", "y"
127
+ next unless point.all?
128
+ next GeoJSON::Point.new point, attributes
129
+ when "esriGeometryMultipoint"
130
+ points = geometry["points"]
131
+ next unless points&.any?
132
+ next GeoJSON::MultiPoint.new points.transpose.take(2).transpose, attributes
133
+ when "esriGeometryPolyline"
134
+ raise Error, "ArcGIS curve geometries not supported" if geometry.key? "curvePaths"
135
+ paths = geometry["paths"]
136
+ next unless paths&.any?
137
+ next GeoJSON::LineString.new paths[0], attributes if paths.one?
138
+ next GeoJSON::MultiLineString.new paths, attributes
139
+ when "esriGeometryPolygon"
140
+ raise Error, "ArcGIS curve geometries not supported" if geometry.key? "curveRings"
141
+ rings = geometry["rings"]
142
+ next unless rings&.any?
143
+ rings.each(&:reverse!) unless rings[0].anticlockwise?
144
+ next GeoJSON::Polygon.new rings, attributes if rings.one?
145
+ next GeoJSON::MultiPolygon.new rings.slice_before(&:anticlockwise?).to_a, attributes
146
+ else
147
+ raise Error, "unsupported ArcGIS geometry type: #{geometry_type}"
148
+ end
149
+ end.compact
150
+
151
+ GeoJSON::Collection.new projection, features
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,70 @@
1
+ module NSWTopo
2
+ module DEM
3
+ include GDALGlob, Log
4
+
5
+ def get_dem(temp_dir, dem_path)
6
+ txt_path = temp_dir / "dem.txt"
7
+ vrt_path = temp_dir / "dem.vrt"
8
+ te = @map.bounds(margin: margin).transpose.flatten
9
+
10
+ Dir.chdir(@source ? @source.parent : Pathname.pwd) do
11
+ log_update "%s: examining DEM" % @name
12
+ gdal_rasters @path do |index, total|
13
+ log_update "%s: examining DEM file %i of %i" % [@name, index, total]
14
+ end
15
+ end.tap do |rasters|
16
+ raise "no elevation data found at specified path" if rasters.none?
17
+ log_update "%s: extracting DEM raster" % @name
18
+ end.group_by do |path, info|
19
+ Projection.new info.dig("coordinateSystem", "wkt")
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")
22
+
23
+ paths, resolutions = rasters.map do |path, info|
24
+ [path, info["geoTransform"].values_at(1, 2).norm]
25
+ end.sort_by(&:last).transpose
26
+
27
+ txt_path.write paths.reverse.join(?\n)
28
+ @resolution ||= resolutions.first
29
+
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
33
+ indexed_dem_path
34
+ end.tap do |dem_paths|
35
+ txt_path.write dem_paths.join(?\n)
36
+ OS.gdalbuildvrt "-overwrite", "-input_file_list", txt_path, vrt_path
37
+ OS.gdal_translate vrt_path, dem_path
38
+ end
39
+ end
40
+
41
+ def blur_dem(dem_path, blur_path)
42
+ sigma = @smooth * @map.scale / 1000.0
43
+ half = (3 * sigma / @resolution).ceil
44
+
45
+ coeffs = (-half..half).map do |n|
46
+ n * @resolution / sigma
47
+ end.map do |x|
48
+ Math::exp(-x**2)
49
+ end
50
+
51
+ vrt = OS.gdalbuildvrt "/vsistdout/", dem_path
52
+ 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|
56
+ kernel_filtered_source.add_element element
57
+ end
58
+ kernel = kernel_filtered_source.add_element("Kernel", "normalized" => 1)
59
+ kernel.add_element("Size").text = coeffs.size
60
+ kernel.add_element("Coefs").text = coeffs.join ?\s
61
+ complex_source.parent.delete complex_source
62
+ end
63
+
64
+ log_update "%s: smoothing DEM raster" % @name
65
+ OS.gdal_translate "/vsistdin/", blur_path do |stdin|
66
+ stdin.write xml
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,77 @@
1
+ module NSWTopo
2
+ class ESRIHdr
3
+ def initialize(path_or_object, *args)
4
+ @header = case path_or_object
5
+ when Pathname then path_or_object.sub_ext(".hdr").each_line.map(&:upcase).map(&:split).to_h
6
+ when ESRIHdr then path_or_object.header.dup
7
+ end
8
+
9
+ @format = case @header.values_at "PIXELTYPE", "NBITS", "BYTEORDER"
10
+ when %w[SIGNEDINT 8 I] then "c*"
11
+ when %w[SIGNEDINT 8 M] then "c*"
12
+ when %w[SIGNEDINT 16 I] then "s<*"
13
+ when %w[SIGNEDINT 16 M] then "s>*"
14
+ when %w[SIGNEDINT 32 I] then "l<*"
15
+ when %w[SIGNEDINT 32 M] then "l>*"
16
+ when %w[UNSIGNEDINT 8 I] then "C*"
17
+ when %w[UNSIGNEDINT 8 M] then "C*"
18
+ when %w[UNSIGNEDINT 16 I] then "S<*"
19
+ when %w[UNSIGNEDINT 16 M] then "S>*"
20
+ when %w[UNSIGNEDINT 32 I] then "L<*"
21
+ when %w[UNSIGNEDINT 32 M] then "L>*"
22
+ when %w[FLOAT 32 I] then "e*"
23
+ when %w[FLOAT 32 M] then "g*"
24
+ end
25
+
26
+ @nodata = case path_or_object
27
+ when Pathname
28
+ case @header.values_at "PIXELTYPE", "NBITS"
29
+ when %w[SIGNEDINT 8] then args.take(1).pack("c").unpack("c").first
30
+ when %w[UNSIGNEDINT 8] then args.take(1).pack("C").unpack("C").first
31
+ when %w[SIGNEDINT 16] then args.take(1).pack("s").unpack("s").first
32
+ when %w[UNSIGNEDINT 16] then args.take(1).pack("S").unpack("S").first
33
+ when %w[SIGNEDINT 32] then args.take(1).pack("l").unpack("l").first
34
+ when %w[UNSIGNEDINT 32] then args.take(1).pack("L").unpack("L").first
35
+ when %w[FLOAT 32] then args.first
36
+ else abort @header.inspect
37
+ end if args.any?
38
+ when ESRIHdr then path_or_object.nodata
39
+ end
40
+
41
+ @values = case path_or_object
42
+ when Pathname
43
+ path_or_object.sub_ext(".bil").binread.unpack(@format).map do |value|
44
+ value == @nodata ? nil : value
45
+ end
46
+ when ESRIHdr then args[0]
47
+ end
48
+ end
49
+
50
+ def write(path)
51
+ @header.map do |pair|
52
+ "%-#{@header.keys.map(&:length).max}s %s\n" % pair
53
+ end.join('').tap do |text|
54
+ path.sub_ext(".hdr").write text
55
+ end
56
+ @values.map do |value|
57
+ value || @nodata
58
+ end.pack(@format).tap do |data|
59
+ path.sub_ext(".bil").binwrite data
60
+ end
61
+ end
62
+
63
+ attr_reader :header, :values, :nodata
64
+
65
+ def nrows
66
+ @nrows ||= @header["NROWS"].to_i
67
+ end
68
+
69
+ def ncols
70
+ @ncols ||= @header["NCOLS"].to_i
71
+ end
72
+
73
+ def rows
74
+ @values.each_slice ncols
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,41 @@
1
+ module NSWTopo
2
+ module GDALGlob
3
+ def gdal_raster?(format)
4
+ @gdal_formats ||= OS.gdalinfo("--formats").each_line.drop(1).map do |line|
5
+ line.strip.split(?\s).first
6
+ end - %w[PDF]
7
+ @gdal_formats.include? format
8
+ end
9
+
10
+ def gdal_rasters(path)
11
+ paths = Array(path).flat_map do |path|
12
+ Pathname.glob Pathname(path).expand_path
13
+ end
14
+
15
+ total = nil
16
+ Enumerator.new do |yielder|
17
+ while path = paths.pop
18
+ OS.gdalmanage("identify", "-r", "-u", path).each_line.map do |line|
19
+ line.chomp.split ": "
20
+ end.each do |component, format|
21
+ case
22
+ when gdal_raster?(format) then yielder << component
23
+ when path == component
24
+ when component !~ /\.zip$/
25
+ else paths << "/vsizip/#{component}"
26
+ end
27
+ end
28
+ end
29
+ end.entries.tap do |paths|
30
+ total = paths.length
31
+ end.map.with_index do |path, index|
32
+ yield [index + 1, total] if block_given?
33
+ info = JSON.parse OS.gdalinfo("-json", path)
34
+ next unless info["geoTransform"]
35
+ next unless wkt = info.dig("coordinateSystem", "wkt")
36
+ next path, info
37
+ rescue JSON::ParserError
38
+ end.compact
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,94 @@
1
+ module NSWTopo
2
+ module GeoJSON
3
+ class Collection
4
+ def initialize(projection = Projection.wgs84, features = [])
5
+ @projection, @features = projection, features
6
+ end
7
+ attr_reader :projection, :features
8
+
9
+ def self.load(json, projection = nil)
10
+ 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|
14
+ geometry, properties = feature.values_at "geometry", "properties"
15
+ type, coordinates = geometry.values_at "type", "coordinates"
16
+ raise Error, "unsupported geometry type: #{type}" unless TYPES === type
17
+ GeoJSON.const_get(type).new coordinates, properties
18
+ end.yield_self do |features|
19
+ new projection, features
20
+ end
21
+ rescue JSON::ParserError
22
+ raise Error, "invalid GeoJSON data"
23
+ end
24
+
25
+ def <<(feature)
26
+ tap { @features << feature }
27
+ end
28
+ alias push <<
29
+
30
+ include Enumerable
31
+ def each(&block)
32
+ block_given? ? tap { @features.each(&block) } : @features.each
33
+ end
34
+
35
+ def reproject_to(projection)
36
+ return self if self.projection == projection
37
+ json = OS.ogr2ogr "-t_srs", projection, "-f", "GeoJSON", "-lco", "RFC7946=NO", "/vsistdout/", "GeoJSON:/vsistdin/" do |stdin|
38
+ stdin.puts to_json
39
+ end
40
+ Collection.load json, projection
41
+ end
42
+
43
+ def reproject_to_wgs84
44
+ reproject_to Projection.wgs84
45
+ end
46
+
47
+ def to_h
48
+ {
49
+ "type" => "FeatureCollection",
50
+ "crs" => { "type" => "name", "properties" => { "name" => @projection } },
51
+ "features" => map(&:to_h)
52
+ }
53
+ end
54
+
55
+ extend Forwardable
56
+ delegate %i[coordinates properties] => :first
57
+ delegate %i[reject! select!] => :@features
58
+
59
+ def to_json(**extras)
60
+ to_h.merge(extras).to_json
61
+ end
62
+
63
+ def explode
64
+ Collection.new @projection, flat_map(&:explode)
65
+ end
66
+
67
+ def multi
68
+ Collection.new @projection, map(&:multi)
69
+ end
70
+
71
+ def merge(other)
72
+ raise Error, "can't merge different projections" unless @projection == other.projection
73
+ Collection.new @projection, @features + other.features
74
+ end
75
+
76
+ def merge!(other)
77
+ raise Error, "can't merge different projections" unless @projection == other.projection
78
+ tap { @features.concat other.features }
79
+ end
80
+
81
+ def clip!(hull)
82
+ @features.map! do |feature|
83
+ feature.clip hull
84
+ end.compact!
85
+ self
86
+ end
87
+
88
+ # TODO: what about empty collections?
89
+ def bounds
90
+ map(&:bounds).transpose.map(&:flatten).map(&:minmax)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,11 @@
1
+ module NSWTopo
2
+ module GeoJSON
3
+ class LineString
4
+ delegate %i[length offset buffer smooth samples] => :multi
5
+
6
+ def bounds
7
+ @coordinates.transpose.map(&:minmax)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,63 @@
1
+ module NSWTopo
2
+ module GeoJSON
3
+ class MultiLineString
4
+ include StraightSkeleton
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
+ def length
29
+ @coordinates.sum(&:path_length)
30
+ end
31
+
32
+ def offset(*margins, **options)
33
+ linestrings = margins.inject Nodes.new(@coordinates) do |nodes, margin|
34
+ nodes.progress limit: margin, **options.slice(:rounding_angle, :cutoff_angle)
35
+ end.readout
36
+ MultiLineString.new linestrings, @properties
37
+ end
38
+
39
+ def buffer(*margins, **options)
40
+ MultiLineString.new(@coordinates + @coordinates.map(&:reverse), @properties).offset(*margins, **options)
41
+ end
42
+
43
+ def smooth(margin, **options)
44
+ linestrings = Nodes.new(@coordinates).tap do |nodes|
45
+ nodes.progress **options.slice(:rounding_angle).merge(limit: margin)
46
+ nodes.progress **options.slice(:rounding_angle, :cutoff_angle).merge(limit: -2 * margin)
47
+ nodes.progress **options.slice(:rounding_angle, :cutoff_angle).merge(limit: margin)
48
+ end.readout
49
+ MultiLineString.new linestrings, @properties
50
+ end
51
+
52
+ def samples(interval)
53
+ points = @coordinates.map do |linestring|
54
+ distance = linestring.path_length
55
+ linestring.sample_at(interval, along: true).map do |point, along|
56
+ [point, (2 * along - distance).abs - distance]
57
+ end
58
+ end.flatten(1).sort_by(&:last).map(&:first)
59
+ MultiPoint.new points, @properties
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,12 @@
1
+ module NSWTopo
2
+ module GeoJSON
3
+ class MultiPoint
4
+ def clip(hull)
5
+ points = [hull, hull.perps].transpose.inject(@coordinates) do |result, (vertex, perp)|
6
+ result.select { |point| point.minus(vertex).dot(perp) >= 0 }
7
+ end
8
+ points.none? ? nil : points.one? ? Point.new(*points, @properties) : MultiPoint.new(points, @properties)
9
+ end
10
+ end
11
+ end
12
+ end