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
@@ -1,11 +1,152 @@
1
1
  module NSWTopo
2
2
  module GeoJSON
3
3
  class LineString
4
- delegate %i[length offset buffer smooth samples] => :multi
4
+ include SVG
5
+
6
+ delegate %i[length offset buffer smooth samples subdivide to_polygon] => :multi
7
+
8
+ def self.[](coordinates, properties = nil, &block)
9
+ new(coordinates, properties) do
10
+ sanitised = @coordinates.map do |point|
11
+ Vector === point ? point : Vector[*point]
12
+ end.chunk(&:itself).map(&:first)
13
+ @coordinates.replace sanitised
14
+ block.call self if block_given?
15
+ end
16
+ end
5
17
 
6
18
  def bounds
7
19
  @coordinates.transpose.map(&:minmax)
8
20
  end
21
+
22
+ def reverse
23
+ LineString.new @coordinates.reverse, @properties
24
+ end
25
+
26
+ def path_length
27
+ each_cons(2).sum { |p0, p1| (p1 - p0).norm }
28
+ end
29
+
30
+ def closed?
31
+ @coordinates.last == @coordinates.first
32
+ end
33
+
34
+ def signed_area
35
+ each_cons(2).sum { |p0, p1| p0.cross(p1) } / 2
36
+ end
37
+
38
+ def clockwise?
39
+ signed_area < 0
40
+ end
41
+ alias interior? clockwise?
42
+
43
+ def anticlockwise?
44
+ signed_area >= 0
45
+ end
46
+ alias exterior? anticlockwise?
47
+
48
+ def simplify(tolerance)
49
+ chunks, simplified = [@coordinates], []
50
+ while chunk = chunks.pop
51
+ direction = (chunk.last - chunk.first).normalised
52
+ delta, index = chunk.map do |point|
53
+ (point - chunk.first).cross(direction).abs
54
+ end.each.with_index.max_by(&:first)
55
+ if delta < tolerance
56
+ simplified.prepend chunk.first
57
+ else
58
+ chunks << chunk[0..index] << chunk[index..-1]
59
+ end
60
+ end
61
+ simplified << @coordinates.last
62
+ LineString.new simplified, @properties
63
+ end
64
+
65
+ def sample_at(interval, offset: 0, &block)
66
+ Enumerator.new do |yielder|
67
+ alpha = (0.5 + Float(offset || 0) / interval) % 1.0
68
+ each_cons(2).inject [alpha, 0] do |(alpha, along), (p0, p1)|
69
+ angle = (p1 - p0).angle
70
+ loop do
71
+ distance = (p1 - p0).norm
72
+ fraction = alpha * interval / distance
73
+ break unless fraction < 1
74
+ p0 = p1 * fraction + p0 * (1 - fraction)
75
+ along += alpha * interval
76
+ block_given? ? yielder << block.call(p0, along, angle) : yielder << p0
77
+ alpha = 1.0
78
+ end
79
+ distance = (p1 - p0).norm
80
+ next alpha - distance / interval, along + distance
81
+ end
82
+ end.entries
83
+ end
84
+
85
+ def segmentise(interval)
86
+ LineString.new sample_at(interval).push(@coordinates.last), @properties
87
+ end
88
+
89
+ def smooth_window(window)
90
+ [@coordinates.take(1)*(window-1), @coordinates, @coordinates.last(1)*(window-1)].flatten(1).each_cons(window).map do |points|
91
+ points.inject(&:+) / window
92
+ end.then do |smoothed|
93
+ LineString.new smoothed, @properties
94
+ end
95
+ end
96
+
97
+ def trim(amount)
98
+ return self unless amount > 0
99
+ ending, total = path_length - amount, 0
100
+ trimmed = each_cons(2).with_object [] do |(p0, p1), trimmed|
101
+ delta = (p1 - p0).norm
102
+ case
103
+ when total >= ending then break trimmed
104
+ when total <= amount - delta
105
+ when total <= amount
106
+ trimmed << (p0 * (delta + total - amount) + p1 * (amount - total)) / delta
107
+ trimmed << (p0 * (delta + total - ending) + p1 * (ending - total)) / delta if total + delta >= ending
108
+ else
109
+ trimmed << p0
110
+ trimmed << (p0 * (delta + total - ending) + p1 * (ending - total)) / delta if total + delta >= ending
111
+ end
112
+ total += delta
113
+ end
114
+ LineString.new trimmed, @properties
115
+ end
116
+
117
+ def crop(length)
118
+ trim((path_length - length) / 2)
119
+ end
120
+
121
+ def svg_path_data(bezier: false)
122
+ if bezier
123
+ fraction = Numeric === bezier ? bezier.clamp(0, 1) : 1
124
+ extras = closed? ? [@coordinates[-2], *@coordinates, @coordinates[2]] : [@coordinates.first, *@coordinates, @coordinates.last]
125
+ midpoints = extras.each_cons(2).map do |p0, p1|
126
+ (p0 + p1) / 2
127
+ end
128
+ distances = extras.each_cons(2).map do |p0, p1|
129
+ (p1 - p0).norm
130
+ end
131
+ offsets = midpoints.zip(distances).each_cons(2).map do |(m0, d0), (m1, d1)|
132
+ (m0 * d1 + m1 * d0) / (d0 + d1)
133
+ end.zip(@coordinates).map do |p0, p1|
134
+ p1 - p0
135
+ end
136
+ controls = midpoints.each_cons(2).zip(offsets).flat_map do |(m0, m1), offset|
137
+ next m0 + offset * fraction, m1 + offset * fraction
138
+ end.drop(1).each_slice(2).entries.prepend(nil)
139
+ zip(controls).map do |point, controls|
140
+ controls ? "C %s %s %s" % [POINT, POINT, POINT] % [*controls.flatten, *point] : "M %s" % POINT % point
141
+ end.join(" ")
142
+ else
143
+ map do |point|
144
+ POINT % point
145
+ end.join(" L ").tap do |string|
146
+ string.concat(" Z") if closed?
147
+ end.prepend("M ")
148
+ end
149
+ end
9
150
  end
10
151
  end
11
152
  end
@@ -3,12 +3,21 @@ module NSWTopo
3
3
  class MultiLineString
4
4
  include StraightSkeleton
5
5
 
6
- def length
7
- @coordinates.sum(&:path_length)
6
+ def freeze!
7
+ each { }
8
+ freeze
9
+ end
10
+
11
+ def path_length
12
+ sum(&:path_length)
13
+ end
14
+
15
+ def nodes
16
+ Nodes.new self
8
17
  end
9
18
 
10
19
  def offset(*margins, **options)
11
- linestrings = margins.inject Nodes.new(@coordinates) do |nodes, margin|
20
+ linestrings = margins.inject nodes do |nodes, margin|
12
21
  nodes.progress limit: margin, **options.slice(:rounding_angle, :cutoff_angle)
13
22
  end.readout
14
23
  MultiLineString.new linestrings, @properties
@@ -19,7 +28,7 @@ module NSWTopo
19
28
  end
20
29
 
21
30
  def smooth(margin, **options)
22
- linestrings = Nodes.new(@coordinates).tap do |nodes|
31
+ linestrings = nodes.tap do |nodes|
23
32
  nodes.progress **options.slice(:rounding_angle).merge(limit: margin)
24
33
  nodes.progress **options.slice(:rounding_angle, :cutoff_angle).merge(limit: -2 * margin)
25
34
  nodes.progress **options.slice(:rounding_angle, :cutoff_angle).merge(limit: margin)
@@ -28,13 +37,46 @@ module NSWTopo
28
37
  end
29
38
 
30
39
  def samples(interval)
31
- points = @coordinates.flat_map do |linestring|
40
+ sampled = flat_map do |linestring|
32
41
  distance = linestring.path_length
33
- linestring.sample_at(interval, along: true).map do |point, along|
42
+ linestring.sample_at(interval) do |point, along, angle|
34
43
  [point, (2 * along - distance).abs - distance]
35
44
  end
36
45
  end.sort_by(&:last).map(&:first)
37
- MultiPoint.new points, @properties
46
+ MultiPoint.new sampled, @properties
47
+ end
48
+
49
+ def dissolve_points
50
+ MultiPoint.new @coordinates.flatten(1), @properties
51
+ end
52
+
53
+ def subdivide(count)
54
+ subdivided = flat_map do |linestring|
55
+ linestring.each_cons(2).each_slice(count).map do |pairs|
56
+ pairs.inject { |part, (p0, p1)| part << p1 }
57
+ end
58
+ end
59
+ MultiLineString.new subdivided, @properties
60
+ end
61
+
62
+ def trim(amount)
63
+ map do |feature|
64
+ feature.trim amount
65
+ end.reject(&:empty?).inject(empty_linestrings, &:+)
66
+ end
67
+
68
+ def to_polygon
69
+ Polygon.new @coordinates, @properties
70
+ end
71
+
72
+ def to_multipolygon
73
+ unclaimed, exterior_rings = partition(&:interior?)
74
+ exterior_rings.sort_by(&:signed_area).map(&:to_polygon).map do |polygon|
75
+ interior_rings, unclaimed = unclaimed.partition do |ring|
76
+ polygon.contains? ring.first
77
+ end
78
+ interior_rings.inject(polygon, &:add_ring)
79
+ end.inject(empty_polygons, &:+)
38
80
  end
39
81
  end
40
82
  end
@@ -0,0 +1,87 @@
1
+ module NSWTopo
2
+ module GeoJSON
3
+ class MultiPoint
4
+ def self.[](coordinates, properties = nil, &block)
5
+ new(coordinates, properties) do
6
+ @coordinates.map! do |point|
7
+ Vector === point ? point : Vector[*point]
8
+ end
9
+ block.call self if block_given?
10
+ end
11
+ end
12
+
13
+ def bounds
14
+ @coordinates.transpose.map(&:minmax)
15
+ end
16
+
17
+ alias dissolve_points itself
18
+
19
+ def rotate_by_degrees(angle)
20
+ rotated = @coordinates.map do |point|
21
+ point.rotate_by_degrees(angle)
22
+ end
23
+ MultiPoint.new rotated, @properties
24
+ end
25
+
26
+ def convex_hull
27
+ start = @coordinates.min
28
+ points, remaining = @coordinates.partition { |point| point == start }
29
+ remaining.sort_by do |point|
30
+ next (point - start).angle, (point - start).norm
31
+ end.inject(points) do |points, p2|
32
+ while points.length > 1 do
33
+ p0, p1 = points.last(2)
34
+ (p2 - p0).cross(p1 - p0) < 0 ? break : points.pop
35
+ end
36
+ points << p2
37
+ end.then do |points|
38
+ LineString.new points, @properties
39
+ end
40
+ end
41
+
42
+ def minimum_bbox_angle(*margins)
43
+ ring = convex_hull.coordinates
44
+ return 0 if ring.one?
45
+ indices = [%i[min_by max_by], %i[x y]].inject(:product).map do |min, coord|
46
+ ring.map(&coord).each.with_index.send(min, &:first).last
47
+ end
48
+ calipers = [Vector[0, -1], Vector[1, 0], Vector[0, 1], Vector[-1, 0]]
49
+ rotation = 0.0
50
+ candidates = []
51
+
52
+ while rotation < Math::PI / 2
53
+ edges = indices.map do |index|
54
+ ring[(index + 1) % ring.length] - ring[index]
55
+ end
56
+ angle, which = [edges, calipers].transpose.map do |edge, caliper|
57
+ Math::acos caliper.proj(edge).clamp(-1, 1)
58
+ end.map.with_index.min_by(&:first)
59
+
60
+ calipers.map! { |caliper| caliper.rotate_by(angle) }
61
+ rotation += angle
62
+
63
+ break if rotation >= Math::PI / 2
64
+
65
+ dimensions = [0, 1].map do |offset|
66
+ (ring[indices[offset + 2]] - ring[indices[offset]]).proj(calipers[offset + 1])
67
+ end
68
+
69
+ if rotation < Math::PI / 4
70
+ candidates << [dimensions, rotation]
71
+ else
72
+ candidates << [dimensions.reverse, rotation - Math::PI / 2]
73
+ end
74
+
75
+ indices[which] += 1
76
+ indices[which] %= ring.length
77
+ end
78
+
79
+ candidates.min_by do |dimensions, rotation|
80
+ dimensions.zip(margins).map do |dimension, margin|
81
+ margin ? dimension + 2 * margin : dimension
82
+ end.inject(:*)
83
+ end.last
84
+ end
85
+ end
86
+ end
87
+ end
@@ -3,14 +3,27 @@ module NSWTopo
3
3
  class MultiPolygon
4
4
  include StraightSkeleton
5
5
 
6
+ def freeze!
7
+ each { }
8
+ freeze
9
+ end
10
+
11
+ def rings
12
+ MultiLineString.new @coordinates.flatten(1), @properties
13
+ end
14
+
6
15
  def area
7
- @coordinates.flatten(1).sum(&:signed_area)
16
+ rings.sum(&:signed_area)
17
+ end
18
+
19
+ def nodes
20
+ Nodes.new rings
8
21
  end
9
22
 
10
23
  def skeleton
11
24
  segments = []
12
- Nodes.new(@coordinates.flatten(1)).progress do |event, node0, node1|
13
- segments << [node0.point, node1.point].to_f
25
+ nodes.progress do |event, node0, node1|
26
+ segments << [node0.point.to_f, node1.point.to_f]
14
27
  end
15
28
  MultiLineString.new segments, @properties
16
29
  end
@@ -19,7 +32,7 @@ module NSWTopo
19
32
  neighbours = Hash.new { |neighbours, node| neighbours[node] = [] }
20
33
  samples, tails, node1 = {}, {}, nil
21
34
 
22
- Nodes.new(@coordinates.flatten(1)).progress(interval: interval) do |event, *args|
35
+ nodes.progress(interval: interval) do |event, *args|
23
36
  case event
24
37
  when :nodes
25
38
  node0, node1 = *args
@@ -28,7 +41,7 @@ module NSWTopo
28
41
  when :interval
29
42
  travel, rings = *args
30
43
  samples[travel] = rings.flat_map do |ring|
31
- ring.sample_at interval
44
+ LineString.new(ring).sample_at(interval)
32
45
  end
33
46
  end
34
47
  end
@@ -52,7 +65,7 @@ module NSWTopo
52
65
  neighbours.delete node
53
66
  neighbours[neighbour].delete node
54
67
  nodes, length = tails.delete(node) || [[node], 0]
55
- candidate = [nodes << neighbour, length + [node.point, neighbour.point].distance]
68
+ candidate = [nodes << neighbour, length + (node.point - neighbour.point).norm]
56
69
  tails[neighbour] = [tails[neighbour], candidate].compact.max_by(&:last)
57
70
  end.any?
58
71
  end
@@ -61,7 +74,7 @@ module NSWTopo
61
74
  while candidates.any?
62
75
  (*nodes, node), length = candidates.pop
63
76
  next if (neighbours[node] - nodes).each do |neighbour|
64
- candidates << [[*nodes, node, neighbour], length + [node.point, neighbour.point].distance]
77
+ candidates << [[*nodes, node, neighbour], length + (node.point - neighbour.point).norm]
65
78
  end.any?
66
79
  index = nodes.find(&:index).index
67
80
  tail_nodes, tail_length = tails[node] || [[node], 0]
@@ -72,7 +85,7 @@ module NSWTopo
72
85
  nodes.chunk do |node|
73
86
  node.travel >= min_travel
74
87
  end.select(&:first).map(&:last).reject(&:one?).map do |nodes|
75
- nodes.map(&:point).to_f
88
+ nodes.map(&:point).map(&:to_f)
76
89
  end
77
90
  end
78
91
  features.prepend MultiLineString.new(linestrings, @properties)
@@ -87,30 +100,29 @@ module NSWTopo
87
100
  end
88
101
 
89
102
  def buffer(*margins, **options)
90
- nodes = Nodes.new @coordinates.flatten(1)
91
- margins.each do |margin|
92
- nodes.progress limit: -margin, **options.slice(:rounding_angle, :cutoff_angle)
93
- end
94
- interior_rings, exterior_rings = nodes.readout.partition(&:hole?)
95
- polygons, foo = exterior_rings.sort_by(&:signed_area).inject [[], interior_rings] do |(polygons, interior_rings), exterior_ring|
96
- claimed, unclaimed = interior_rings.partition do |interior_ring|
97
- interior_ring.first.within? exterior_ring
98
- end
99
- [polygons << [exterior_ring, *claimed], unclaimed]
100
- end
101
- MultiPolygon.new polygons.entries, @properties
103
+ rings.offset(*margins.map(&:-@), **options).to_multipolygon
102
104
  end
103
105
 
104
106
  def centroids
105
- MultiPoint.new @coordinates.map(&:first).map(&:centroid), @properties
107
+ map(&:centroid).inject(empty_points, &:+)
106
108
  end
107
109
 
108
110
  def samples(interval)
109
- points = @coordinates.flatten(1).flat_map do |ring|
110
- ring.sample_at interval
111
+ points = rings.flat_map do |coordinates|
112
+ linestring.sample_at(interval)
111
113
  end
112
114
  MultiPoint.new points, @properties
113
115
  end
116
+
117
+ def dissolve_points
118
+ MultiPoint.new @coordinates.flatten(2), @properties
119
+ end
120
+
121
+ def remove_holes(&block)
122
+ map do |polygon|
123
+ polygon.remove_holes(&block)
124
+ end.inject(empty_polygons, &:+)
125
+ end
114
126
  end
115
127
  end
116
128
  end
@@ -1,8 +1,23 @@
1
1
  module NSWTopo
2
2
  module GeoJSON
3
3
  class Point
4
+ def self.[](coordinates, properties = nil, &block)
5
+ new(coordinates, properties) do
6
+ @coordinates = Vector[*@coordinates] unless Vector === @coordinates
7
+ block.call self if block_given?
8
+ end
9
+ end
10
+
4
11
  def bounds
5
- @coordinates.zip.map(&:minmax)
12
+ zip.map(&:minmax)
13
+ end
14
+
15
+ def empty?
16
+ false
17
+ end
18
+
19
+ def rotate_by_degrees(angle)
20
+ Point.new @coordinates.rotate_by_degrees(angle), @properties
6
21
  end
7
22
  end
8
23
  end
@@ -1,26 +1,88 @@
1
1
  module NSWTopo
2
2
  module GeoJSON
3
3
  class Polygon
4
- delegate %i[area skeleton centres centrepoints centrelines buffer centroids samples] => :multi
4
+ include SVG
5
5
 
6
- def validate!
7
- @coordinates.inject(false) do |hole, ring|
8
- ring.reverse! if hole ^ ring.hole?
9
- true
6
+ def self.[](coordinates, properties = nil, &block)
7
+ new(coordinates, properties) do
8
+ @coordinates.each.with_index do |coordinates, index|
9
+ LineString[coordinates] do |ring|
10
+ ring.coordinates << ring.first unless ring.closed?
11
+ ring.coordinates.reverse! if index.zero? ^ ring.exterior?
12
+ end
13
+ end
14
+ block.call self if block_given?
10
15
  end
11
16
  end
12
17
 
18
+ def freeze!
19
+ @coordinates.each(&:freeze)
20
+ freeze
21
+ end
22
+
23
+ delegate %i[skeleton centres centrepoints centrelines buffer samples] => :multi
24
+
13
25
  def bounds
14
- @coordinates.first.transpose.map(&:minmax)
26
+ first.transpose.map(&:minmax)
15
27
  end
16
28
 
17
29
  def wkt
18
- @coordinates.map do |ring|
30
+ map do |ring|
19
31
  ring.map do |point|
20
32
  point.join(" ")
21
33
  end.join(", ").prepend("(").concat(")")
22
34
  end.join(", ").prepend("POLYGON (").concat(")")
23
35
  end
36
+
37
+ def centroid
38
+ flat_map do |ring|
39
+ ring.each_cons(2).map do |p0, p1|
40
+ next (p0 + p1) * p0.cross(p1), 3 * p0.cross(p1)
41
+ end
42
+ end.transpose.then do |centroids_x6, signed_areas_x6|
43
+ point = centroids_x6.inject(&:+) / signed_areas_x6.inject(&:+)
44
+ Point.new point, @properties
45
+ end
46
+ end
47
+
48
+ def rings
49
+ MultiLineString.new @coordinates, @properties
50
+ end
51
+
52
+ def add_ring(ring)
53
+ Polygon.new [*@coordinates, ring.coordinates], @properties
54
+ end
55
+
56
+ def area
57
+ rings.sum(&:signed_area)
58
+ end
59
+
60
+ def remove_holes(&block)
61
+ rings.reject_linestrings do |ring|
62
+ ring.interior? && (block_given? ? block.call(ring) : true)
63
+ end.to_polygon
64
+ end
65
+
66
+ def contains?(geometry)
67
+ geometry = Point.new(geometry) if Vector === geometry
68
+ geometry.dissolve_points.coordinates.all? do |point|
69
+ sum do |ring|
70
+ ring.each_cons(2).inject(0) do |winding, (p0, p1)|
71
+ case
72
+ when p1.y > point.y && p0.y <= point.y && (p0 - p1).cross(p0 - point) >= 0 then winding + 1
73
+ when p0.y > point.y && p1.y <= point.y && (p1 - p0).cross(p0 - point) >= 0 then winding - 1
74
+ when p0.y == point.y && p1.y == point.y && p0.x >= point.x && p1.x < point.x then winding + 1
75
+ when p0.y == point.y && p1.y == point.y && p1.x >= point.x && p0.x < point.x then winding - 1
76
+ else winding
77
+ end
78
+ end
79
+ end.nonzero?
80
+ end
81
+ end
82
+
83
+ def svg_path_data
84
+ rings.map(&:svg_path_data).join(?\s)
85
+ end
24
86
  end
25
87
  end
26
88
  end