nswtopo 3.0.1 → 3.1.1

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/bin/nswtopo +20 -4
  3. data/docs/contours.md +2 -0
  4. data/docs/relief.md +2 -3
  5. data/docs/spot-heights.md +2 -0
  6. data/lib/nswtopo/archive.rb +6 -3
  7. data/lib/nswtopo/chrome.rb +9 -6
  8. data/lib/nswtopo/commands/layers.rb +2 -2
  9. data/lib/nswtopo/config.rb +1 -0
  10. data/lib/nswtopo/formats/gemf.rb +1 -0
  11. data/lib/nswtopo/formats/kmz.rb +16 -10
  12. data/lib/nswtopo/formats/mbtiles.rb +1 -0
  13. data/lib/nswtopo/formats/pdf.rb +4 -3
  14. data/lib/nswtopo/formats/svg.rb +5 -13
  15. data/lib/nswtopo/formats/svgz.rb +1 -0
  16. data/lib/nswtopo/formats/zip.rb +5 -4
  17. data/lib/nswtopo/formats.rb +35 -36
  18. data/lib/nswtopo/geometry/r_tree.rb +24 -23
  19. data/lib/nswtopo/geometry/straight_skeleton/node.rb +4 -4
  20. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +51 -40
  21. data/lib/nswtopo/geometry/straight_skeleton/split.rb +2 -2
  22. data/lib/nswtopo/geometry/vector.rb +55 -49
  23. data/lib/nswtopo/geometry.rb +0 -5
  24. data/lib/nswtopo/gis/arcgis/layer/map.rb +11 -10
  25. data/lib/nswtopo/gis/arcgis/layer/query.rb +8 -10
  26. data/lib/nswtopo/gis/arcgis/layer.rb +7 -11
  27. data/lib/nswtopo/gis/dem.rb +3 -2
  28. data/lib/nswtopo/gis/gdal_glob.rb +3 -3
  29. data/lib/nswtopo/gis/geojson/collection.rb +60 -14
  30. data/lib/nswtopo/gis/geojson/line_string.rb +142 -1
  31. data/lib/nswtopo/gis/geojson/multi_line_string.rb +49 -7
  32. data/lib/nswtopo/gis/geojson/multi_point.rb +87 -0
  33. data/lib/nswtopo/gis/geojson/multi_polygon.rb +35 -23
  34. data/lib/nswtopo/gis/geojson/point.rb +16 -1
  35. data/lib/nswtopo/gis/geojson/polygon.rb +69 -7
  36. data/lib/nswtopo/gis/geojson.rb +92 -46
  37. data/lib/nswtopo/gis/projection.rb +5 -1
  38. data/lib/nswtopo/helpers/thread_pool.rb +39 -0
  39. data/lib/nswtopo/helpers.rb +44 -5
  40. data/lib/nswtopo/layer/arcgis_raster.rb +4 -6
  41. data/lib/nswtopo/layer/contour.rb +24 -26
  42. data/lib/nswtopo/layer/control.rb +5 -3
  43. data/lib/nswtopo/layer/declination.rb +14 -10
  44. data/lib/nswtopo/layer/feature.rb +5 -5
  45. data/lib/nswtopo/layer/grid.rb +19 -18
  46. data/lib/nswtopo/layer/labels/barriers.rb +23 -0
  47. data/lib/nswtopo/layer/labels/convex_hull.rb +12 -0
  48. data/lib/nswtopo/layer/labels/convex_hulls.rb +86 -0
  49. data/lib/nswtopo/layer/labels/label.rb +63 -0
  50. data/lib/nswtopo/layer/labels.rb +192 -315
  51. data/lib/nswtopo/layer/overlay.rb +11 -12
  52. data/lib/nswtopo/layer/raster.rb +1 -0
  53. data/lib/nswtopo/layer/relief.rb +6 -4
  54. data/lib/nswtopo/layer/spot.rb +11 -17
  55. data/lib/nswtopo/layer/{vector → vector_render}/cutout.rb +1 -1
  56. data/lib/nswtopo/layer/{vector → vector_render}/knockout.rb +2 -3
  57. data/lib/nswtopo/layer/{vector.rb → vector_render.rb} +20 -45
  58. data/lib/nswtopo/layer.rb +2 -1
  59. data/lib/nswtopo/map.rb +70 -56
  60. data/lib/nswtopo/svg.rb +5 -0
  61. data/lib/nswtopo/tiled_web_map.rb +3 -3
  62. data/lib/nswtopo/tree_indenter.rb +2 -2
  63. data/lib/nswtopo/version.rb +1 -1
  64. data/lib/nswtopo.rb +4 -0
  65. metadata +15 -17
  66. data/lib/nswtopo/geometry/overlap.rb +0 -47
  67. data/lib/nswtopo/geometry/segment.rb +0 -27
  68. data/lib/nswtopo/geometry/vector_sequence.rb +0 -180
  69. data/lib/nswtopo/helpers/array.rb +0 -19
  70. data/lib/nswtopo/helpers/concurrently.rb +0 -27
  71. data/lib/nswtopo/helpers/dir.rb +0 -7
  72. data/lib/nswtopo/helpers/hash.rb +0 -15
  73. data/lib/nswtopo/helpers/tar_writer.rb +0 -11
  74. data/lib/nswtopo/layer/labels/barrier.rb +0 -39
@@ -9,20 +9,30 @@ module StraightSkeleton
9
9
 
10
10
  def initialize(data)
11
11
  @active, @indices = Set[], Hash.new.compare_by_identity
12
- data.to_d.map do |points|
12
+ data.map do |points|
13
+ points.map(&:to_d)
14
+ end.map do |points|
13
15
  next points unless points.length > 2
14
16
  points.each.with_object [] do |point, points|
15
17
  points << point unless points.last == point
16
18
  end
17
- end.map.with_index do |(*points, point), index|
18
- points.first == point ? [points, :ring, (index unless points.hole?)] : [points << point, :segments, nil]
19
- end.each do |points, pair, index|
20
- normals = points.send(pair).map(&:diff).map(&:normalised).map(&:perp)
19
+ end.each.with_index do |points, index|
20
+ closed = points.first == points.last
21
+ points.each_cons(2).sum do |p0, p1|
22
+ p0.cross(p1)
23
+ end.then do |signed_area_x2|
24
+ index = nil if signed_area_x2 < 0
25
+ end if closed
26
+ normals = points.each_cons(2).map do |p0, p1|
27
+ (p1 - p0).normalised.perp
28
+ end
21
29
  points.map do |point|
22
30
  Vertex.new self, point
23
- end.each do |node|
24
- @active << node
25
- end.send(pair).zip(normals).each do |edge, normal|
31
+ end.tap do |nodes|
32
+ nodes.pop if closed
33
+ nodes.inject(@active, &:<<)
34
+ nodes << nodes.first if closed
35
+ end.each_cons(2).zip(normals) do |edge, normal|
26
36
  Nodes.stitch normal, *edge
27
37
  @indices[normal] = index if index
28
38
  end
@@ -40,8 +50,8 @@ module StraightSkeleton
40
50
  det = n2.cross(n1) + n1.cross(n0) + n0.cross(n2)
41
51
  return if det.zero?
42
52
  travel = (x0 * n1.cross(n2) + x1 * n2.cross(n0) + x2 * n0.cross(n1)) / det
43
- point = [n1.minus(n2).perp.times(x0), n2.minus(n0).perp.times(x1), n0.minus(n1).perp.times(x2)].inject(&:plus) / det
44
- [point, travel]
53
+ point = ((n1 - n2).perp * x0 + (n2 - n0).perp * x1 + (n0 - n1).perp * x2) / det
54
+ return point, travel
45
55
  end
46
56
 
47
57
  # #################################
@@ -52,11 +62,11 @@ module StraightSkeleton
52
62
  # #################################
53
63
 
54
64
  def self.solve_asym(n0, n1, n2, x0, x1, x2)
55
- det = n0.minus(n1).dot(n2)
65
+ det = (n0 - n1).dot(n2)
56
66
  return if det.zero?
57
67
  travel = (x0 * n1.dot(n2) - x1 * n2.dot(n0) + x2 * n0.cross(n1)) / det
58
- point = (n2.times(x0 - x1).plus n0.minus(n1).perp.times(x2)) / det
59
- [point, travel]
68
+ point = (n2 * (x0 - x1) + (n0 - n1).perp * x2) / det
69
+ return point, travel
60
70
  end
61
71
 
62
72
  def collapse(edge)
@@ -79,14 +89,14 @@ module StraightSkeleton
79
89
  bounds = node.project(@limit).zip(node.point).map do |centre, coord|
80
90
  [coord, centre - @limit, centre + @limit].minmax
81
91
  end if @limit
82
- @index.search(bounds).map do |edge|
92
+ @index.search(bounds).filter_map do |edge|
83
93
  p0, p1, p2 = [*edge, node].map(&:point)
84
94
  t0, t1, t2 = [*edge, node].map(&:travel)
85
95
  (n00, n01), (n10, n11), (n20, n21) = [*edge, node].map(&:normals)
86
96
  next if p0 == p2 || p1 == p2
87
97
  next if node.terminal? and Split === node and node.source.normals[0].equal? n01
88
98
  next if node.terminal? and Split === node and node.source.normals[1].equal? n01
89
- next unless node.terminal? || [n20, n21].compact.inject(&:plus).dot(n01) < 0
99
+ next unless node.terminal? || [n20, n21].compact.inject(&:+).dot(n01) < 0
90
100
  point, travel = case
91
101
  when n20 && n21 then Nodes::solve(n20, n21, n01, n20.dot(p2) - t2, n21.dot(p2) - t2, n01.dot(p0) - t0)
92
102
  when n20 then Nodes::solve_asym(n01, n20, n20, n01.dot(p0) - t0, n20.dot(p2) - t2, n20.cross(p2))
@@ -94,9 +104,9 @@ module StraightSkeleton
94
104
  end || next
95
105
  next if travel * @direction < node.travel
96
106
  next if @limit && travel.abs > @limit.abs
97
- next if point.minus(p0).dot(n01) * @direction < 0
107
+ next if (point - p0).dot(n01) * @direction < 0
98
108
  Split.new self, point, travel, node, edge[0]
99
- end.compact.each do |split|
109
+ end.each do |split|
100
110
  @candidates << split
101
111
  end
102
112
  end
@@ -110,7 +120,7 @@ module StraightSkeleton
110
120
  @track[node.normals[1]] << node if node.normals[1]
111
121
  2.times.inject [node] do |nodes|
112
122
  [nodes.first.prev, *nodes, nodes.last.next].compact
113
- end.segments.uniq.each do |edge|
123
+ end.each_cons(2).uniq.each do |edge|
114
124
  collapse edge
115
125
  end
116
126
  split node if node.splits?
@@ -123,7 +133,7 @@ module StraightSkeleton
123
133
  end
124
134
 
125
135
  def nodeset
126
- [].tap do |result|
136
+ Enumerator.new do |yielder|
127
137
  pending, processed = @active.dup, Set[]
128
138
  while pending.any?
129
139
  nodes = pending.take 1
@@ -137,35 +147,36 @@ module StraightSkeleton
137
147
  end
138
148
  pending.subtract nodes
139
149
  nodes << nodes.first if nodes.first == nodes.last.next
140
- result << nodes unless nodes.one?
150
+ yielder << nodes unless nodes.one?
141
151
  end
142
- end
152
+ end.entries
143
153
  end
144
154
 
145
155
  attr_reader :direction
146
156
 
147
157
  def progress(limit: nil, rounding_angle: DEFAULT_ROUNDING_ANGLE, cutoff_angle: nil, interval: nil, splits: true, &block)
148
- return self if limit && limit.zero?
158
+ return self if limit&.zero?
149
159
 
150
160
  nodeset.tap do
151
161
  @active.clear
152
162
  @indices = nil
153
- end.map do |*nodes, node|
154
- nodes.first == node ? [nodes, :ring] : [nodes << node, :segments]
155
- end.each.with_index do |(nodes, pair), index|
156
- normals = nodes.send(pair).map do |edge|
157
- edge[0].normals[1]
163
+ end.each do |nodes|
164
+ closed = nodes.first == nodes.last
165
+ normals = nodes.map do |node|
166
+ node.normals[1]
158
167
  end
159
168
  nodes.map do |node|
160
169
  Vertex.new self, node.project(@limit)
161
- end.each do |node|
162
- @active << node
163
- end.send(pair).zip(normals).each do |edge, normal|
170
+ end.tap do |nodes|
171
+ nodes.pop if closed
172
+ nodes.inject(@active, &:<<)
173
+ nodes << nodes.first if closed
174
+ end.each_cons(2).zip(normals).each do |edge, normal|
164
175
  Nodes.stitch normal, *edge
165
176
  end
166
177
  end if @limit
167
178
 
168
- @candidates, @travel, @limit, @direction = AVLTree.new, 0, limit && limit.to_d, limit ? limit <=> 0 : 1
179
+ @candidates, @travel, @limit, @direction = AVLTree.new, 0, limit&.to_d, limit ? limit <=> 0 : 1
169
180
 
170
181
  rounding_angle *= Math::PI / 180
171
182
  cutoff_angle *= Math::PI / 180 if cutoff_angle
@@ -189,10 +200,12 @@ module StraightSkeleton
189
200
  events << [:outgoing, node.next] if node.next
190
201
  end.sort_by do |event, node|
191
202
  case event
192
- when :incoming then [-@direction * node.normals[1].angle, 1]
193
- when :outgoing then [-@direction * node.normals[0].negate.angle, 0]
203
+ when :incoming then [-@direction * node.normals[1].angle, 1]
204
+ when :outgoing then [-@direction * (-node.normals[0]).angle, 0]
194
205
  end
195
- end.ring.map(&:transpose).each do |events, neighbours|
206
+ end.then do |events|
207
+ events.zip events.rotate
208
+ end.map(&:transpose).each do |events, neighbours|
196
209
  node = Vertex.new self, point
197
210
  case events
198
211
  when [:outgoing, :incoming] then next
@@ -236,9 +249,9 @@ module StraightSkeleton
236
249
  end.each do |extra_node|
237
250
  @active << extra_node
238
251
  end
239
- edges = [node.neighbours[0], node, *extra_nodes, node.neighbours[1]].segments
252
+ edges = [node.neighbours[0], node, *extra_nodes, node.neighbours[1]].each_cons(2)
240
253
  normals = [node.normals[0], *extra_normals, node.normals[1]]
241
- edges.zip(normals).each do |edge, normal|
254
+ edges.zip(normals) do |edge, normal|
242
255
  Nodes.stitch normal, *edge
243
256
  end
244
257
  end
@@ -251,7 +264,7 @@ module StraightSkeleton
251
264
  end.map do |edge|
252
265
  [edge.map(&:point).transpose.map(&:minmax), edge]
253
266
  end.tap do |bounds_edges|
254
- @index = RTree.load bounds_edges
267
+ @index = RTree.load! bounds_edges
255
268
  end
256
269
 
257
270
  @active.select(&:splits?).each do |node|
@@ -281,9 +294,7 @@ module StraightSkeleton
281
294
  node.project(travel).to_f
282
295
  end
283
296
  end.map do |points|
284
- points.segments.reject do |segment|
285
- segment.inject(&:==)
286
- end.map(&:last).unshift(points.first)
297
+ points.chunk(&:itself).map(&:first)
287
298
  end.reject(&:one?)
288
299
  end
289
300
 
@@ -13,8 +13,8 @@ module StraightSkeleton
13
13
  @edge = @nodes.track(@normal).find do |edge|
14
14
  (n00, n01), (n10, n11) = edge.map(&:normals)
15
15
  p0, p1 = edge.map(&:point)
16
- next if point.minus(p0).cross(n00 ? n00.plus(n01) : n01) < 0
17
- next if point.minus(p1).cross(n11 ? n11.plus(n10) : n10) > 0
16
+ next if (point - p0).cross(n00 ? n00 + n01 : n01) < 0
17
+ next if (point - p1).cross(n11 ? n11 + n10 : n10) > 0
18
18
  true
19
19
  end
20
20
  end
@@ -1,56 +1,86 @@
1
- module Vector
1
+ class Vector
2
+ def initialize(x, y)
3
+ @x, @y = x, y
4
+ freeze
5
+ end
6
+
7
+ def self.[](x, y)
8
+ new x, y
9
+ end
10
+
11
+ attr_reader :x, :y
12
+
13
+ def to_ary
14
+ [@x, @y]
15
+ end
16
+
17
+ extend Forwardable
18
+ delegate %i[to_json hash each join <=>] => :to_ary
19
+
20
+ include Enumerable
21
+ include Comparable
22
+ alias eql? ==
23
+
24
+ def inspect
25
+ "{%s, %s}" % [@x, @y]
26
+ end
27
+
28
+ def to_d
29
+ Vector[@x.to_d, @y.to_d]
30
+ end
31
+
32
+ def to_f
33
+ Vector[@x.to_f, @y.to_f]
34
+ end
35
+
2
36
  def rotate_by(angle)
3
37
  cos = Math::cos(angle)
4
38
  sin = Math::sin(angle)
5
- [self[0] * cos - self[1] * sin, self[0] * sin + self[1] * cos]
6
- end
7
-
8
- def rotate_by!(angle)
9
- replace rotate_by(angle)
39
+ Vector[@x * cos - @y * sin, @x * sin + @y * cos]
10
40
  end
11
41
 
12
42
  def rotate_by_degrees(angle)
13
43
  rotate_by(angle * Math::PI / 180.0)
14
44
  end
15
45
 
16
- def rotate_by_degrees!(angle)
17
- replace rotate_by_degrees(angle)
46
+ def +(other)
47
+ Vector[@x + other.x, @y + other.y]
18
48
  end
19
49
 
20
- def plus(other)
21
- [self, other].transpose.map { |values| values.inject(:+) }
50
+ def -(other)
51
+ Vector[@x - other.x, @y - other.y]
22
52
  end
23
53
 
24
- def minus(other)
25
- [self, other].transpose.map { |values| values.inject(:-) }
54
+ def *(scalar)
55
+ Vector[@x * scalar, @y * scalar]
26
56
  end
27
57
 
28
- def dot(other)
29
- [self, other].transpose.map { |values| values.inject(:*) }.inject(:+)
58
+ def /(scalar)
59
+ Vector[@x / scalar, @y / scalar]
30
60
  end
31
61
 
32
- def times(scalar)
33
- map { |value| value * scalar }
62
+ def +@
63
+ self
34
64
  end
35
65
 
36
- def /(scalar)
37
- map { |value| value / scalar }
66
+ def -@
67
+ self * -1
38
68
  end
39
69
 
40
- def negate
41
- map { |value| -value }
70
+ def dot(other)
71
+ @x * other.x + @y * other.y
42
72
  end
43
73
 
44
- def to_d
45
- map(&:to_d)
74
+ def perp
75
+ Vector[-@y, @x]
46
76
  end
47
77
 
48
- def to_f
49
- map(&:to_f)
78
+ def cross(other)
79
+ perp.dot other
50
80
  end
51
81
 
52
82
  def angle
53
- Math::atan2 at(1), at(0)
83
+ Math::atan2 @y, @x
54
84
  end
55
85
 
56
86
  def norm
@@ -64,28 +94,4 @@ module Vector
64
94
  def proj(other)
65
95
  dot(other) / other.norm
66
96
  end
67
-
68
- def perp
69
- [-self[1], self[0]]
70
- end
71
-
72
- def cross(other)
73
- perp.dot other
74
- end
75
-
76
- def within?(polygon)
77
- polygon.map do |point|
78
- point.minus self
79
- end.ring.inject(0) do |winding, (p0, p1)|
80
- case
81
- when p1[1] > 0 && p0[1] <= 0 && p0.minus(p1).cross(p0) >= 0 then winding + 1
82
- when p0[1] > 0 && p1[1] <= 0 && p1.minus(p0).cross(p0) >= 0 then winding - 1
83
- when p0[1] == 0 && p1[1] == 0 && p0[0] >= 0 && p1[0] < 0 then winding + 1
84
- when p0[1] == 0 && p1[1] == 0 && p1[0] >= 0 && p0[0] < 0 then winding - 1
85
- else winding
86
- end
87
- end != 0
88
- end
89
97
  end
90
-
91
- Array.send :include, Vector
@@ -1,8 +1,3 @@
1
- require 'bigdecimal'
2
- require 'bigdecimal/util'
3
1
  require_relative 'geometry/vector'
4
- require_relative 'geometry/segment'
5
- require_relative 'geometry/vector_sequence'
6
- require_relative 'geometry/overlap'
7
2
  require_relative 'geometry/r_tree'
8
3
  require_relative 'geometry/straight_skeleton'
@@ -116,10 +116,13 @@ module NSWTopo
116
116
  curves = [[coords.last.last, *points]]
117
117
  while curve = curves.shift
118
118
  next if curve.first == curve.last
119
- if curve.values_at(0,-1).distance < 0.99 * curve.segments.map(&:distance).sum
119
+ curve_length = curve.each_cons(2).sum do |p0, p1|
120
+ (p1 - p0).norm
121
+ end
122
+ if (curve.first - curve.last).norm < 0.99 * curve_length
120
123
  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)]
124
+ reduced << reduced.last.each_cons(2).map do |p0, p1|
125
+ (p0 + p1) / 2
123
126
  end
124
127
  end
125
128
  curves.unshift reduced.map(&:last).reverse
@@ -141,15 +144,13 @@ module NSWTopo
141
144
  when "esriGeometryPoint"
142
145
  raise "unexpected SVG response (bad point symbol)" unless coords.map(&:length) == [ 4 ]
143
146
  point = coords[0].transpose.map { |coords| coords.sum / coords.length }
144
- next GeoJSON::Point.new point, properties
147
+ next GeoJSON::Point[point, properties]
145
148
  when "esriGeometryPolyline"
146
- next GeoJSON::LineString.new coords[0], properties if @mixed && coords.one?
147
- next GeoJSON::MultiLineString.new coords, properties
149
+ next GeoJSON::LineString[coords[0], properties] if @mixed && coords.one?
150
+ next GeoJSON::MultiLineString[coords, properties]
148
151
  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
152
+ polys = GeoJSON::MultiLineString[coords.map(&:reverse), properties].to_multipolygon
153
+ next @mixed && polys.one? ? polys.first : polys
153
154
  end
154
155
  end.tap do |features|
155
156
  yielder << GeoJSON::Collection.new(projection: projection, name: @name, features: features)
@@ -45,7 +45,7 @@ module NSWTopo
45
45
  get_json "#{@id}/query", outFields: out_fields, objectIds: objectids.take(per_page).join(?,)
46
46
  rescue Connection::Error
47
47
  (per_page /= 2) > 0 ? retry : raise
48
- end.fetch("features", []).map do |feature|
48
+ end.fetch("features", []).filter_map do |feature|
49
49
  next unless geometry = feature["geometry"]
50
50
  properties = feature.fetch("attributes", {})
51
51
 
@@ -53,29 +53,27 @@ module NSWTopo
53
53
  when "esriGeometryPoint"
54
54
  point = geometry.values_at "x", "y"
55
55
  next unless point.all?
56
- next GeoJSON::Point.new point, properties
56
+ next GeoJSON::Point[point, properties]
57
57
  when "esriGeometryMultipoint"
58
58
  points = geometry["points"]
59
59
  next unless points&.any?
60
- next GeoJSON::MultiPoint.new points.transpose.take(2).transpose, properties
60
+ next GeoJSON::MultiPoint[points.transpose.take(2).transpose, properties]
61
61
  when "esriGeometryPolyline"
62
62
  raise "ArcGIS curve geometries not supported" if geometry.key? "curvePaths"
63
63
  paths = geometry["paths"]
64
64
  next unless paths&.any?
65
- next GeoJSON::LineString.new paths[0], properties if @mixed && paths.one?
66
- next GeoJSON::MultiLineString.new paths, properties
65
+ next GeoJSON::LineString[paths[0], properties] if @mixed && paths.one?
66
+ next GeoJSON::MultiLineString[paths, properties]
67
67
  when "esriGeometryPolygon"
68
68
  raise "ArcGIS curve geometries not supported" if geometry.key? "curveRings"
69
69
  rings = geometry["rings"]
70
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
71
+ polys = GeoJSON::MultiLineString[rings.map(&:reverse), properties].to_multipolygon
72
+ next @mixed && polys.one? ? polys.first : polys
75
73
  else
76
74
  raise "unsupported ArcGIS geometry type: #{@geometry_type}"
77
75
  end
78
- end.compact.tap do |features|
76
+ end.tap do |features|
79
77
  yielder << GeoJSON::Collection.new(projection: projection, features: features, name: @name)
80
78
  end
81
79
  objectids.shift per_page
@@ -133,20 +133,16 @@ module NSWTopo
133
133
  def decode(attributes)
134
134
  attributes.map do |name, value|
135
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)
136
+ end.to_h.slice(*@fields)
143
137
  end
144
138
 
145
139
  def paged(per_page: nil)
146
140
  per_page = [*per_page, *@layer["maxRecordCount"], 500].min
147
141
  Enumerator::Lazy.new pages(per_page) do |yielder, page|
148
- page.each(&method(:transform))
149
- yielder << page
142
+ page.map! do |feature|
143
+ decoded = decode(feature.properties).transform_keys!(&@rename)
144
+ feature.with_properties decoded
145
+ end.then(&yielder)
150
146
  end
151
147
  end
152
148
 
@@ -173,9 +169,9 @@ module NSWTopo
173
169
  end
174
170
 
175
171
  def counts
176
- classify(*@fields, *extra_field).each do |attributes, count|
172
+ classify(*@fields, *extra_field).group_by do |attributes, count|
177
173
  decode attributes
178
- end.group_by(&:first).map do |attributes, attributes_counts|
174
+ end.map do |attributes, attributes_counts|
179
175
  [attributes, attributes_counts.sum(&:last)]
180
176
  end
181
177
  end
@@ -16,12 +16,13 @@ module NSWTopo
16
16
  raise "no elevation data found at specified path" if rasters.none?
17
17
  log_update "%s: extracting DEM raster" % @name
18
18
  end.group_by do |path, info|
19
- Projection.new info.dig("coordinateSystem", "wkt")
19
+ @epsg ? Projection.epsg(@epsg) : Projection.new(info.dig("coordinateSystem", "wkt"))
20
20
  end.map.with_index do |(projection, rasters), index|
21
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
- [path, info["geoTransform"].values_at(1, 2).norm]
24
+ rx, ry = info["geoTransform"].values_at(1, 2)
25
+ next path, Vector[rx, ry].norm
25
26
  end.sort_by(&:last).transpose
26
27
 
27
28
  txt_path.write paths.reverse.join(?\n)
@@ -28,14 +28,14 @@ module NSWTopo
28
28
  end
29
29
  end.entries.tap do |paths|
30
30
  total = paths.length
31
- end.map.with_index do |path, index|
31
+ end.filter_map.with_index do |path, index|
32
32
  yield [index + 1, total] if block_given?
33
33
  info = JSON.parse OS.gdalinfo("-json", path)
34
34
  next unless info["geoTransform"]
35
- next unless wkt = info.dig("coordinateSystem", "wkt")
35
+ next unless @epsg || info.dig("coordinateSystem", "wkt")
36
36
  next path, info
37
37
  rescue JSON::ParserError
38
- end.compact
38
+ end
39
39
  end
40
40
  end
41
41
  end
@@ -19,7 +19,7 @@ module NSWTopo
19
19
  geometry, properties = feature.values_at "geometry", "properties"
20
20
  type, coordinates = geometry.values_at "type", "coordinates"
21
21
  raise Error, "unsupported geometry type: #{type}" unless TYPES === type
22
- GeoJSON.const_get(type).new coordinates, properties
22
+ GeoJSON.const_get(type)[coordinates, properties]
23
23
  end.then do |features|
24
24
  new projection: projection, features: features, name: name
25
25
  end
@@ -37,6 +37,14 @@ module NSWTopo
37
37
  block_given? ? tap { @features.each(&block) } : @features.each
38
38
  end
39
39
 
40
+ def map!(&block)
41
+ tap { @features.map!(&block) }
42
+ end
43
+
44
+ def reject!(&block)
45
+ tap { @features.reject!(&block) }
46
+ end
47
+
40
48
  def reproject_to(projection)
41
49
  return self if self.projection == projection
42
50
  json = OS.ogr2ogr "-t_srs", projection, "-f", "GeoJSON", "-lco", "RFC7946=NO", "/vsistdout/", "GeoJSON:/vsistdin/" do |stdin|
@@ -52,32 +60,48 @@ module NSWTopo
52
60
  def to_h
53
61
  {
54
62
  "type" => "FeatureCollection",
63
+ "name" => @name,
55
64
  "crs" => { "type" => "name", "properties" => { "name" => @projection } },
56
65
  "features" => map(&:to_h)
57
- }.tap do |hash|
58
- hash["name"] = @name if @name
59
- end
66
+ }.compact
60
67
  end
61
68
 
62
69
  extend Forwardable
63
- delegate %i[coordinates properties wkt area] => :first
64
- delegate %i[reject! select! length] => :@features
70
+ delegate %i[coordinates properties wkt area svg_path_data] => :first
71
+ delegate %i[length] => :@features
65
72
 
66
73
  def to_json(**extras)
67
74
  to_h.merge(extras).to_json
68
75
  end
69
76
 
77
+ def with_features(features)
78
+ Collection.new projection: @projection, name: @name, features: features
79
+ end
80
+
81
+ def with_name(name)
82
+ Collection.new projection: @projection, name: name, features: @features
83
+ end
84
+
85
+ def with_sql(sql, name: @name)
86
+ json = OS.ogr2ogr *%w[-f GeoJSON -lco RFC7946=NO /vsistdout/ GeoJSON:/vsistdin/ -dialect SQLite -sql], sql do |stdin|
87
+ stdin.puts to_json
88
+ end
89
+ Collection.load(json, projection: @projection).with_name(name)
90
+ rescue OS::Error
91
+ raise "GDAL with SQLite support required"
92
+ end
93
+
70
94
  def explode
71
- Collection.new projection: @projection, name: @name, features: flat_map(&:explode)
95
+ with_features flat_map(&:explode)
72
96
  end
73
97
 
74
98
  def multi
75
- Collection.new projection: @projection, name: @name, features: map(&:multi)
99
+ with_features map(&:multi)
76
100
  end
77
101
 
78
102
  def merge(other)
79
103
  raise Error, "can't merge different projections" unless @projection == other.projection
80
- Collection.new projection: @projection, name: @name, features: @features + other.features
104
+ with_features @features + other.features
81
105
  end
82
106
 
83
107
  def merge!(other)
@@ -85,6 +109,19 @@ module NSWTopo
85
109
  tap { @features.concat other.features }
86
110
  end
87
111
 
112
+ def dissolve_points
113
+ with_features map(&:dissolve_points)
114
+ end
115
+
116
+ def union
117
+ return self if none?
118
+ with_features [inject(&:+)]
119
+ end
120
+
121
+ def rotate_by_degrees!(angle)
122
+ map! { |feature| feature.rotate_by_degrees(angle) }
123
+ end
124
+
88
125
  def clip(polygon)
89
126
  OS.ogr2ogr "-f", "GeoJSON", "-lco", "RFC7946=NO", "-clipsrc", polygon.wkt, "/vsistdout/", "GeoJSON:/vsistdin/" do |stdin|
90
127
  stdin.puts to_json
@@ -97,14 +134,10 @@ module NSWTopo
97
134
  map do |feature|
98
135
  feature.buffer(*margins, **options)
99
136
  end.then do |features|
100
- Collection.new projection: @projection, name: @name, features: features
137
+ with_features features
101
138
  end
102
139
  end
103
140
 
104
- def rename(name = nil)
105
- tap { @name = name }
106
- end
107
-
108
141
  # TODO: what about empty collections?
109
142
  def bounds
110
143
  map(&:bounds).transpose.map(&:flatten).map(&:minmax)
@@ -113,6 +146,19 @@ module NSWTopo
113
146
  def bbox
114
147
  GeoJSON.polygon [bounds.inject(&:product).values_at(0,2,3,1,0)], projection: @projection
115
148
  end
149
+
150
+ def bbox_centre
151
+ midpoint = bounds.map { |min, max| (max + min) / 2 }
152
+ GeoJSON.point midpoint, projection: @projection
153
+ end
154
+
155
+ def bbox_extents
156
+ bounds.map { |min, max| max - min }
157
+ end
158
+
159
+ def minimum_bbox_angle(*margins)
160
+ dissolve_points.union.first.minimum_bbox_angle(*margins)
161
+ end
116
162
  end
117
163
  end
118
164
  end