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
@@ -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