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
@@ -3,12 +3,12 @@ class RTree
3
3
  @nodes, @bounds, @object = nodes, bounds, object
4
4
  end
5
5
 
6
- def overlaps?(bounds)
6
+ def overlaps?(bounds, buffer)
7
7
  return false if @bounds.empty?
8
8
  return true unless bounds
9
9
  bounds.zip(@bounds).all? do |bound1, bound2|
10
10
  bound1.zip(bound2.rotate).each.with_index.all? do |limits, index|
11
- limits.rotate(index).inject(&:<=)
11
+ limits.rotate(index).inject(&:-) <= buffer
12
12
  end
13
13
  end
14
14
  end
@@ -31,17 +31,23 @@ class RTree
31
31
  end
32
32
  end
33
33
 
34
- def search(bounds, searched = Set.new)
34
+ def search(bounds, buffer: 0, searched: Set.new)
35
35
  Enumerator.new do |yielder|
36
- unless searched.include? self
37
- if overlaps? bounds
38
- @nodes.each do |node|
39
- node.search(bounds, searched).each { |object| yielder << object }
40
- end
41
- yielder << @object if @nodes.empty?
36
+ next if searched.include? self
37
+ if overlaps? bounds, buffer
38
+ @nodes.each do |node|
39
+ node.search(bounds, buffer: buffer, searched: searched).inject(yielder, &:<<)
42
40
  end
43
- searched << self
41
+ yielder << @object if @nodes.empty?
44
42
  end
43
+ searched << self
45
44
  end
46
45
  end
46
+
47
+ def each(&block)
48
+ @nodes.each do |node|
49
+ node.each(&block)
50
+ end
51
+ yield @bounds, @object if @object
52
+ end
47
53
  end
@@ -1,18 +1,18 @@
1
1
  module Segment
2
2
  def segments
3
- self[0..-2].zip self[1..-1]
3
+ each_cons(2).entries
4
4
  end
5
5
 
6
6
  def ring
7
7
  zip rotate
8
8
  end
9
9
 
10
- def difference
10
+ def diff
11
11
  last.minus first
12
12
  end
13
13
 
14
14
  def distance
15
- difference.norm
15
+ diff.norm
16
16
  end
17
17
 
18
18
  def along(fraction)
@@ -11,13 +11,13 @@ module StraightSkeleton
11
11
  @active, @indices = Set[], Hash.new.compare_by_identity
12
12
  data.to_d.map do |points|
13
13
  next points unless points.length > 2
14
- points.inject [] do |points, point|
15
- points.last == point ? points : points << point
14
+ points.each.with_object [] do |point, points|
15
+ points << point unless points.last == point
16
16
  end
17
17
  end.map.with_index do |(*points, point), index|
18
18
  points.first == point ? [points, :ring, (index unless points.hole?)] : [points << point, :segments, nil]
19
19
  end.each do |points, pair, index|
20
- normals = points.send(pair).map(&:difference).map(&:normalised).map(&:perp)
20
+ normals = points.send(pair).map(&:diff).map(&:normalised).map(&:perp)
21
21
  points.map do |point|
22
22
  Vertex.new self, point
23
23
  end.each do |node|
@@ -137,7 +137,7 @@ module StraightSkeleton
137
137
  end
138
138
  pending.subtract nodes
139
139
  nodes << nodes.first if nodes.first == nodes.last.next
140
- result << nodes
140
+ result << nodes unless nodes.one?
141
141
  end
142
142
  end
143
143
  end
@@ -184,10 +184,9 @@ module StraightSkeleton
184
184
  end
185
185
  end.each do |point, nodes|
186
186
  @active.subtract nodes
187
- nodes.inject [] do |events, node|
187
+ nodes.each.with_object [] do |node, events|
188
188
  events << [:incoming, node.prev] if node.prev
189
189
  events << [:outgoing, node.next] if node.next
190
- events
191
190
  end.sort_by do |event, node|
192
191
  case event
193
192
  when :incoming then [-@direction * node.normals[1].angle, 1]
@@ -1,6 +1,6 @@
1
1
  module VectorSequence
2
2
  def perps
3
- ring.map(&:difference).map(&:perp)
3
+ ring.map(&:diff).map(&:perp)
4
4
  end
5
5
 
6
6
  def signed_area
@@ -23,7 +23,7 @@ module VectorSequence
23
23
  end
24
24
 
25
25
  def convex?
26
- ring.map(&:difference).ring.all? do |directions|
26
+ ring.map(&:diff).ring.all? do |directions|
27
27
  directions.inject(&:cross) >= 0
28
28
  end
29
29
  end
@@ -101,7 +101,7 @@ module VectorSequence
101
101
  end
102
102
 
103
103
  def path_length
104
- segments.map(&:difference).sum(&:norm)
104
+ segments.map(&:diff).sum(&:norm)
105
105
  end
106
106
 
107
107
  def trim(margin)
@@ -130,9 +130,10 @@ module VectorSequence
130
130
  trim(0.5 * (path_length - length))
131
131
  end
132
132
 
133
- def sample_at(interval, along: false, angle: false)
133
+ def sample_at(interval, along: false, angle: false, offset: nil)
134
134
  Enumerator.new do |yielder|
135
- segments.inject [0.5, 0] do |(alpha, sum), segment|
135
+ alpha = (0.5 + Float(offset || 0) / interval) % 1.0
136
+ segments.inject [alpha, 0] do |(alpha, sum), segment|
136
137
  loop do
137
138
  fraction = alpha * interval / segment.distance
138
139
  break unless fraction < 1
@@ -140,7 +141,7 @@ module VectorSequence
140
141
  sum += alpha * interval
141
142
  yielder << case
142
143
  when along then [segment[0], sum]
143
- when angle then [segment[0], segment.difference.angle]
144
+ when angle then [segment[0], segment.diff.angle]
144
145
  else segment[0]
145
146
  end
146
147
  alpha = 1.0
@@ -0,0 +1,56 @@
1
+ module NSWTopo
2
+ module ArcGIS
3
+ class Connection
4
+ ERRORS = [Timeout::Error, Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError]
5
+ Error = Class.new RuntimeError
6
+
7
+ def initialize(uri, prefix_path = nil)
8
+ @http = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 600)
9
+ @prefix, @headers = Pathname(prefix_path.to_s), { "User-Agent" => "Ruby/#{RUBY_VERSION}", "Referer" => "%s://%s" % [@http.use_ssl? ? "https" : "http", @http.address] }
10
+ @http.max_retries = 0
11
+ rescue *ERRORS => error
12
+ raise Error, error.message
13
+ end
14
+
15
+ def repeatedly_request(request, &block)
16
+ intervals ||= 4.times.map(&1.4142.method(:**))
17
+ @http.request(request).tap(&:value).then(&block)
18
+ rescue *ERRORS, Error => error
19
+ intervals.any? ? sleep(intervals.shift) : raise(Error, error.message)
20
+ retry
21
+ end
22
+
23
+ def get(relative_path, **query, &block)
24
+ path = @prefix.join(relative_path.to_s).to_s
25
+ path << ?? << URI.encode_www_form(query) unless query.empty?
26
+ request = Net::HTTP::Get.new(path, @headers)
27
+ repeatedly_request(request, &block)
28
+ end
29
+
30
+ def post(relative_path, **query, &block)
31
+ path = @prefix.join(relative_path.to_s).to_s
32
+ request = Net::HTTP::Post.new(path, @headers)
33
+ request.body = URI.encode_www_form(query)
34
+ repeatedly_request(request, &block)
35
+ end
36
+
37
+ def process_json(response)
38
+ JSON.parse(response.body).tap do |result|
39
+ next unless error = result["error"]
40
+ # raise Error, error.values_at("message", "details").compact.join(?\n)
41
+ raise Error, error.values_at("message", "code").map(&:to_s).reject(&:empty?).first
42
+ end
43
+ rescue JSON::ParserError
44
+ raise Error, "unexpected ArcGIS response format"
45
+ end
46
+
47
+ def get_json(relative_path = "", **query)
48
+ get relative_path, **query, f: "json", &method(:process_json)
49
+ end
50
+
51
+ def post_json(relative_path = "", **query)
52
+ post relative_path, **query, f: "json", &method(:process_json)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,163 @@
1
+ module NSWTopo
2
+ module ArcGIS
3
+ module Map
4
+ TILE = 1000
5
+ NoUniqueFieldError = Class.new RuntimeError
6
+
7
+ def pages(per_page)
8
+ objectid_field = @layer["fields"].find do |field|
9
+ field["type"] == "esriFieldTypeOID"
10
+ end&.fetch("name")
11
+
12
+ raise "ArcGIS layer does not support dynamic layers: #{@name}" unless @service["supportsDynamicLayers"]
13
+ raise "ArcGIS layer does not support SVG output: #{@name}" unless @service["supportedImageFormatTypes"].split(?,).include? "SVG"
14
+ raise "ArcGIS layer does not have an objectid field: #{@name}" unless objectid_field
15
+
16
+ @unique ||= @type_field
17
+ @unique ||= @layer["fields"].find do |field|
18
+ field.values_at("name", "alias").map(&:downcase).include? @layer.dig("drawingInfo", "renderer", "field1")&.downcase
19
+ end&.fetch("name")
20
+ @unique ||= @coded_values.min_by do |name, lookup|
21
+ lookup.length
22
+ end&.first
23
+ raise NoUniqueFieldError unless @unique
24
+
25
+ @count = classify(@unique).sum(&:last)
26
+ return [GeoJSON::Collection.new(projection: projection, name: @name)].each if @count.zero?
27
+
28
+ @fields ||= @layer["fields"].select do |field|
29
+ Layer::FIELD_TYPES === field["type"]
30
+ end.map do |field|
31
+ field["name"]
32
+ end
33
+
34
+ include_objectid = @fields.include? objectid_field
35
+ min, chunk, table = 0, 10000, {}
36
+ loop do
37
+ break unless table.length < @count
38
+ page, where = {}, ["#{objectid_field}>=#{min}", "#{objectid_field}<#{min + chunk}", *@where]
39
+ Set[*@fields, *extra_field].delete(objectid_field).each_slice(2) do |fields|
40
+ classify(objectid_field, *fields, where: where).each do |attributes, count|
41
+ objectid = attributes.delete objectid_field
42
+ page[objectid] ||= include_objectid ? { objectid_field => objectid } : {}
43
+ page[objectid].merge! attributes
44
+ end
45
+ end
46
+ rescue Connection::Error
47
+ (chunk /= 2) > 0 ? retry : raise
48
+ else
49
+ table.merge! page
50
+ min += chunk
51
+ end
52
+
53
+ parent = @layer
54
+ scale = loop do
55
+ break parent["minScale"] if parent["minScale"]&.nonzero?
56
+ break parent["effectiveMinScale"] if parent["effectiveMinScale"]&.nonzero?
57
+ break unless parent_id = parent.dig("parentLayer", "id")
58
+ parent = get_json parent_id
59
+ end || begin
60
+ case @service["units"]
61
+ when "esriMeters" then 100000
62
+ else raise "can't get features from layer: #{@name}"
63
+ end
64
+ end
65
+
66
+ bounds = @layer["extent"].values_at("xmin", "xmax", "ymin", "ymax").each_slice(2)
67
+ cx, cy = bounds.map { |bound| 0.5 * bound.sum }
68
+ bbox, size = %W[#{cx},#{cy},#{cx},#{cy} #{TILE},#{TILE}]
69
+ dpi = bounds.map { |b0, b1| 0.0254 * TILE * scale / (b1 - b0) }.min * 0.999
70
+
71
+ renderer = case @geometry_type
72
+ when "esriGeometryPoint"
73
+ { type: "simple", symbol: { color: [0,0,0,255], size: 1, type: "esriSMS", style: "esriSMSSquare" } }
74
+ when "esriGeometryPolyline"
75
+ { type: "simple", symbol: { color: [0,0,0,255], width: 1, type: "esriSLS", style: "esriSLSSolid" } }
76
+ when "esriGeometryPolygon"
77
+ { type: "simple", symbol: { color: [0,0,0,255], width: 0, type: "esriSFS", style: "esriSFSSolid" } }
78
+ else
79
+ raise "unsupported ArcGIS geometry type: #{@geometry_type}"
80
+ end
81
+ dynamic_layer = { source: { type: "mapLayer", mapLayerId: @id }, drawingInfo: { showLabels: false, renderer: renderer } }
82
+
83
+ sets = table.group_by(&:last).map(&:last).sort_by(&:length)
84
+
85
+ Enumerator::Lazy.new(sets) do |yielder, objectids_properties|
86
+ while objectids_properties.any?
87
+ begin
88
+ objectids, properties = objectids_properties.take(per_page).transpose
89
+ dynamic_layers = [dynamic_layer.merge(definitionExpression: "#{objectid_field} IN (#{objectids.join ?,})")]
90
+ export = get_json "export", format: "svg", dynamicLayers: dynamic_layers.to_json, bbox: bbox, size: size, mapScale: scale, dpi: dpi
91
+ href = URI.parse export["href"]
92
+ xml = Connection.new(href).get(href.path, &:body)
93
+ xmin, xmax, ymin, ymax = export["extent"].values_at "xmin", "xmax", "ymin", "ymax"
94
+ rescue Connection::Error
95
+ (per_page /= 2) > 0 ? retry : raise
96
+ end
97
+
98
+ REXML::Document.new(xml).elements.collect("svg//g[@transform]//g[@transform][path[@d]]") do |group|
99
+ a, b, c, d, e, f = group.attributes["transform"].match(/matrix\((.*)\)/)[1].split(?\s).map(&:to_f)
100
+ coords = []
101
+ group.elements["path[@d]"].attributes["d"].gsub(/\ *([MmZzLlHhVvCcSsQqTtAa])\ */) do
102
+ ?\s + $1 + ?\s
103
+ end.strip.split(?\s).slice_before(/[MmZzLlHhVvCcSsQqTtAa]/).each do |command, *numbers|
104
+ raise "can't handle SVG path data command: #{command}" unless numbers.length.even?
105
+ coordinates = numbers.each_slice(2).map do |x, y|
106
+ fx, fy = [(a * Float(x) + c * Float(y) + e) / TILE, (b * Float(x) + d * Float(y) + f) / TILE]
107
+ [fx * xmax + (1 - fx) * xmin, fy * ymin + (1 - fy) * ymax]
108
+ end
109
+ case command
110
+ when ?Z then next
111
+ when ?M then coords << coordinates
112
+ when ?L then coords.last.concat coordinates
113
+ when ?C
114
+ coordinates.each_slice(3) do |points|
115
+ raise "unexpected SVG response (bad path data)" unless points.length == 3
116
+ curves = [[coords.last.last, *points]]
117
+ while curve = curves.shift
118
+ next if curve.first == curve.last
119
+ if curve.values_at(0,-1).distance < 0.99 * curve.segments.map(&:distance).sum
120
+ reduced = 3.times.inject [ curve ] do |reduced|
121
+ reduced << reduced.last.each_cons(2).map do |(x0, y0), (x1, y1)|
122
+ [0.5 * (x0 + x1), 0.5 * (y0 + y1)]
123
+ end
124
+ end
125
+ curves.unshift reduced.map(&:last).reverse
126
+ curves.unshift reduced.map(&:first)
127
+ else
128
+ coords.last << curve.last
129
+ end
130
+ end
131
+ end
132
+ else raise "can't handle SVG path data command: #{command}"
133
+ end
134
+ end
135
+ coords
136
+ end.tap do |coords|
137
+ lengths = [properties.length, coords.length]
138
+ raise "unexpected SVG response (expected %i features, received %i)" % lengths if lengths.inject(&:<)
139
+ end.zip(properties).map do |coords, properties|
140
+ case @geometry_type
141
+ when "esriGeometryPoint"
142
+ raise "unexpected SVG response (bad point symbol)" unless coords.map(&:length) == [ 4 ]
143
+ point = coords[0].transpose.map { |coords| coords.sum / coords.length }
144
+ next GeoJSON::Point.new point, properties
145
+ when "esriGeometryPolyline"
146
+ next GeoJSON::LineString.new coords[0], properties if @mixed && coords.one?
147
+ next GeoJSON::MultiLineString.new coords, properties
148
+ when "esriGeometryPolygon"
149
+ coords.each(&:reverse!) unless coords[0].anticlockwise?
150
+ polys = coords.slice_before(&:anticlockwise?).entries
151
+ next GeoJSON::Polygon.new polys.first, properties if @mixed && polys.one?
152
+ next GeoJSON::MultiPolygon.new polys, properties
153
+ end
154
+ end.tap do |features|
155
+ yielder << GeoJSON::Collection.new(projection: projection, name: @name, features: features)
156
+ end
157
+ objectids_properties.shift per_page
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,87 @@
1
+ module NSWTopo
2
+ module ArcGIS
3
+ module Query
4
+ UniqueFieldError = Class.new RuntimeError
5
+
6
+ def base_query(**options)
7
+ case
8
+ when @unique
9
+ raise UniqueFieldError
10
+ when @geometry
11
+ raise "polgyon geometry required" unless @geometry.polygon?
12
+ options[:geometry] = { rings: @geometry.reproject_to(projection).coordinates.map(&:reverse) }.to_json
13
+ options[:geometryType] = "esriGeometryPolygon"
14
+ options[:where] = join_clauses(*@where) if @where
15
+ when @where
16
+ options[:where] = join_clauses(*@where)
17
+ else
18
+ oid_field = @layer["fields"].find do |field|
19
+ field["type"] == "esriFieldTypeOID"
20
+ end&.fetch("name")
21
+ options[:where] = oid_field ? "#{oid_field} IS NOT NULL" : "1=1"
22
+ end
23
+ options
24
+ end
25
+
26
+ def count
27
+ @count ||= get_json("#{@id}/query", **base_query, returnCountOnly: true).dig("count")
28
+ end
29
+
30
+ def pages(per_page)
31
+ objectids = get_json("#{@id}/query", **base_query, returnIdsOnly: true)["objectIds"] || []
32
+ @count = objectids.count
33
+ return [GeoJSON::Collection.new(projection: projection, name: @name)].each if @count.zero?
34
+
35
+ @fields ||= @layer["fields"].select do |field|
36
+ Layer::FIELD_TYPES === field["type"]
37
+ end.map do |field|
38
+ field["name"]
39
+ end
40
+
41
+ Enumerator.new do |yielder|
42
+ out_fields = [*@fields, *extra_field].join ?,
43
+ while objectids.any?
44
+ begin
45
+ get_json "#{@id}/query", outFields: out_fields, objectIds: objectids.take(per_page).join(?,)
46
+ rescue Connection::Error
47
+ (per_page /= 2) > 0 ? retry : raise
48
+ end.fetch("features", []).map do |feature|
49
+ next unless geometry = feature["geometry"]
50
+ properties = feature.fetch("attributes", {})
51
+
52
+ case @geometry_type
53
+ when "esriGeometryPoint"
54
+ point = geometry.values_at "x", "y"
55
+ next unless point.all?
56
+ next GeoJSON::Point.new point, properties
57
+ when "esriGeometryMultipoint"
58
+ points = geometry["points"]
59
+ next unless points&.any?
60
+ next GeoJSON::MultiPoint.new points.transpose.take(2).transpose, properties
61
+ when "esriGeometryPolyline"
62
+ raise "ArcGIS curve geometries not supported" if geometry.key? "curvePaths"
63
+ paths = geometry["paths"]
64
+ next unless paths&.any?
65
+ next GeoJSON::LineString.new paths[0], properties if @mixed && paths.one?
66
+ next GeoJSON::MultiLineString.new paths, properties
67
+ when "esriGeometryPolygon"
68
+ raise "ArcGIS curve geometries not supported" if geometry.key? "curveRings"
69
+ rings = geometry["rings"]
70
+ next unless rings&.any?
71
+ rings.each(&:reverse!) unless rings[0].anticlockwise?
72
+ polys = rings.slice_before(&:anticlockwise?).entries
73
+ next GeoJSON::Polygon.new polys.first, properties if @mixed && polys.one?
74
+ next GeoJSON::MultiPolygon.new polys, properties
75
+ else
76
+ raise "unsupported ArcGIS geometry type: #{@geometry_type}"
77
+ end
78
+ end.compact.tap do |features|
79
+ yielder << GeoJSON::Collection.new(projection: projection, features: features, name: @name)
80
+ end
81
+ objectids.shift per_page
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,66 @@
1
+ module NSWTopo
2
+ module ArcGIS
3
+ module Renderer
4
+ TooManyFieldsError = Class.new RuntimeError
5
+ NoGeometryError = Class.new RuntimeError
6
+
7
+ def classify(*fields, where: @where)
8
+ raise TooManyFieldsError unless fields.size <= 3
9
+ raise NoGeometryError, "ArcGIS layer does not support spatial filtering: #{@name}" if @geometry
10
+
11
+ types = fields.map do |name|
12
+ @layer["fields"].find do |field|
13
+ field["name"] == name
14
+ end&.fetch("type")
15
+ end
16
+
17
+ counts, values = 2.times do |repeat|
18
+ counts, values = %w[| ~ ^ #].find do |delimiter|
19
+ classification_def = { type: "uniqueValueDef", uniqueValueFields: fields.take(repeat) + fields, fieldDelimiter: delimiter }
20
+ unique_values = get_json("#{@id}/generateRenderer", where: join_clauses(*where), classificationDef: classification_def.to_json).fetch("uniqueValueInfos")
21
+
22
+ values = unique_values.map do |info|
23
+ info["value"].split(delimiter).map(&:strip)
24
+ end
25
+ next unless values.all? do |values|
26
+ values.length == fields.length + repeat
27
+ end
28
+ repeat.times { values.each(&:shift) }
29
+ counts = unique_values.map do |info|
30
+ info["count"]
31
+ end
32
+ break counts, values
33
+ end
34
+ raise "couldn't delimit values" unless values
35
+ next if 0 == repeat && fields.one? && (counts.all?(1) || counts.all?(0))
36
+ break counts, values
37
+ end
38
+
39
+ values.map do |values|
40
+ values.zip(types).map do |value, type|
41
+ case
42
+ when value == "<Null>" then nil
43
+ when value == "" then nil
44
+ when type == "esriFieldTypeOID" then Integer(value)
45
+ when type == "esriFieldTypeInteger" then Integer(value)
46
+ when type == "esriFieldTypeSmallInteger" then Integer(value)
47
+ when type == "esriFieldTypeDouble" then Float(value)
48
+ when type == "esriFieldTypeSingle" then Float(value)
49
+ when type == "esriFieldTypeString" then String(value)
50
+ when type == "esriFieldTypeGUID" then String(value)
51
+ when type == "esriFieldTypeDate"
52
+ begin
53
+ Time.strptime(value, "%m/%d/%Y %l:%M:%S %p").to_i * 1000
54
+ rescue ArgumentError
55
+ end
56
+ end
57
+ rescue ArgumentError
58
+ raise "could not interpret #{value.inspect} as #{type}"
59
+ end.then do |values|
60
+ fields.zip values
61
+ end.to_h
62
+ end.zip counts
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,15 @@
1
+ module NSWTopo
2
+ module ArcGIS
3
+ module Statistics
4
+ def classify(*fields)
5
+ statistics = fields.map.with_index do |name, index|
6
+ { statisticType: "count", onStatisticField: name, outStatisticFieldName: "COUNT_#{index}" }
7
+ end
8
+ field_counts = get_json "#{@id}/query", **base_query, outStatistics: statistics.to_json, groupByFieldsForStatistics: fields.join(?,)
9
+ field_counts["features"].map do |feature|
10
+ [feature["attributes"].slice(*fields), feature["attributes"]["COUNT_0"]]
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end