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
data/lib/nswtopo/map.rb CHANGED
@@ -1,32 +1,34 @@
1
1
  module NSWTopo
2
2
  class Map
3
- include Formats, Dither, Zip, Log, Safely
4
-
5
- def initialize(archive, proj4:, scale:, centre:, extents:, rotation:, layers: {})
6
- @archive, @scale, @centre, @extents, @rotation, @layers = archive, scale, centre, extents, rotation, layers
7
- @projection = Projection.new proj4
8
- ox, oy = bounding_box.coordinates[0][3]
9
- @affine = [[1, 0], [0, -1], [-ox, oy]].map do |vector|
10
- vector.rotate_by_degrees(-@rotation).times(1000.0 / @scale)
11
- end.transpose
3
+ include Formats, Dither, Zip, Log, Safely, TiledWebMap
4
+
5
+ def initialize(archive, neatline:, centre:, dimensions:, scale:, rotation:, layers: {})
6
+ @archive, @neatline, @centre, @dimensions, @scale, @rotation, @layers = archive, neatline, centre, dimensions, scale, rotation, layers
7
+ params = { k_0: 1.0 / @scale, units: "mm", x_0: 0.0005 * @dimensions[0], y_0: 0.0005 * @dimensions[1] }
8
+ @projection = rotation.zero? ?
9
+ Projection.transverse_mercator(*centre, **params) :
10
+ Projection.oblique_mercator(*centre, alpha: rotation, **params)
11
+ @cutline = @neatline.reproject_to(@projection)
12
12
  end
13
- attr_reader :projection, :scale, :centre, :extents, :rotation
13
+ attr_reader :centre, :dimensions, :scale, :rotation, :projection
14
14
 
15
15
  extend Forwardable
16
- delegate %i[write mtime read] => :@archive
16
+ delegate %i[write mtime read uptodate?] => :@archive
17
17
 
18
- def self.init(archive, scale: 25000, rotation: 0.0, bounds: nil, coords: nil, dimensions: nil, margins: nil)
19
- wgs84_points = case
18
+ def self.init(archive, scale: 25000, rotation: 0.0, bounds: nil, coords: nil, dimensions: nil, inset: [], margins: nil)
19
+ points = case
20
+ when dimensions && margins
21
+ raise "can't specify both margins and map dimensions"
20
22
  when coords && bounds
21
23
  raise "can't specify both bounds file and map coordinates"
22
24
  when coords
23
25
  coords
24
26
  when bounds
25
- gps = GPS.load bounds
27
+ gps = GPS.load(bounds).explode
26
28
  margins ||= [15, 15] unless dimensions || gps.polygons.any?
27
29
  case
28
30
  when gps.polygons.any?
29
- gps.polygons.map(&:coordinates).flatten(1).inject(&:+)
31
+ gps.polygons.flat_map(&:coordinates).inject(&:+)
30
32
  when gps.linestrings.any?
31
33
  gps.linestrings.map(&:coordinates).inject(&:+)
32
34
  when gps.points.any?
@@ -37,64 +39,123 @@ module NSWTopo
37
39
  else
38
40
  raise "no bounds file or map coordinates specified"
39
41
  end
42
+ margins ||= [0, 0]
40
43
 
41
- wgs84_centre = wgs84_points.transpose.map(&:minmax).map(&:sum).times(0.5)
42
- projection = Projection.azimuthal_equidistant *wgs84_centre
44
+ centre = points.transpose.map(&:minmax).map(&:sum).times(0.5)
45
+ equidistant = Projection.azimuthal_equidistant *centre
43
46
 
44
47
  case rotation
45
48
  when "auto"
46
49
  raise "can't specify both map dimensions and auto-rotation" if dimensions
47
- points = GeoJSON.multipoint(wgs84_points).reproject_to(projection).coordinates
48
- centre, extents, rotation = points.minimum_bounding_box(*margins)
50
+ local_points = GeoJSON.multipoint(points).reproject_to(equidistant).coordinates
51
+ local_centre, local_extents, rotation = local_points.minimum_bounding_box(*margins)
49
52
  rotation *= -180.0 / Math::PI
50
53
  when "magnetic"
51
- rotation = declination(*wgs84_centre)
54
+ rotation = declination(*centre)
52
55
  else
53
56
  raise "map rotation must be between ±45°" unless rotation.abs <= 45
54
57
  end
55
58
 
56
- case
57
- when centre
58
- when dimensions
59
- raise "can't specify both margins and map dimensions" if margins
60
- extents = dimensions.map do |dimension|
61
- dimension * 0.001 * scale
62
- end
63
- centre = GeoJSON.point(wgs84_centre).reproject_to(projection).coordinates
64
- else
65
- points = GeoJSON.multipoint(wgs84_points).reproject_to(projection).coordinates
66
- centre, extents = points.map do |point|
59
+ unless dimensions || local_centre
60
+ local_points = GeoJSON.multipoint(points).reproject_to(equidistant).coordinates
61
+ local_centre, local_extents = local_points.map do |point|
67
62
  point.rotate_by_degrees rotation
68
63
  end.transpose.map(&:minmax).map do |min, max|
69
64
  [0.5 * (max + min), max - min]
70
65
  end.transpose
71
- centre.rotate_by_degrees! -rotation
66
+ local_centre.rotate_by_degrees! -rotation
72
67
  end
73
68
 
74
- wgs84_centre = GeoJSON.point(centre, projection: projection).reproject_to_wgs84.coordinates
75
- projection = Projection.transverse_mercator *wgs84_centre
69
+ unless dimensions
70
+ dimensions = local_extents.times(1000.0 / scale).plus margins.times(2)
71
+ centre = GeoJSON.point(local_centre, projection: equidistant).reproject_to_wgs84.coordinates
72
+ end
76
73
 
77
- extents = extents.zip(margins).map do |extent, margin|
78
- extent + 2 * margin * 0.001 * scale
79
- end if margins
74
+ params = { units: "mm", axis: "esu", k_0: 1.0 / scale, x_0: 0.0005 * dimensions[0], y_0: -0.0005 * dimensions[1] }
75
+ projection = rotation.zero? ?
76
+ Projection.transverse_mercator(*centre, **params) :
77
+ Projection.oblique_mercator(*centre, alpha: rotation, **params)
80
78
 
81
79
  case
82
- when extents.all?(&:positive?)
80
+ when dimensions.all?(&:positive?)
83
81
  when coords
84
82
  raise "not enough information to calculate map size – add more coordinates, or specify map dimensions or margins"
85
83
  when bounds
86
84
  raise "not enough information to calculate map size – check bounds file, or specify map dimensions or margins"
87
85
  end
88
86
 
89
- new(archive, proj4: projection.proj4, scale: scale, centre: [0, 0], extents: extents, rotation: rotation).save
87
+ insets = inset.map do |inset|
88
+ inset.each_slice(2).entries.transpose.map(&:sort)
89
+ end.each.with_object GeoJSON::Collection.new(projection: projection, name: "insets") do |bounds, collection|
90
+ dimensions.zip(bounds).each do |dimension, (min, max)|
91
+ raise OptionParser::InvalidArgument, "inset falls outside map dimensions" unless max > 0 && min < dimension
92
+ end
93
+ collection.add_polygon [bounds.inject(&:product).values_at(0,2,3,1,0)]
94
+ end
95
+
96
+ neatline = if insets.any?
97
+ OS.ogr2ogr *%w[-f GeoJSON -lco RFC7946=NO /vsistdout/ GeoJSON:/vsistdin/ -dialect sqlite -sql], <<~SQL do |stdin|
98
+ SELECT ST_Difference(BuildMbr(0,0,#{dimensions.join ?,}), ST_Union(geometry)) AS geometry
99
+ FROM insets
100
+ SQL
101
+ stdin.puts insets.to_json
102
+ end.then do |json|
103
+ GeoJSON::Collection.load(json, projection: projection, name: "neatline").explode
104
+ end
105
+ else
106
+ ring = [[0, 0], dimensions].transpose.inject(&:product).values_at(0,2,3,1,0)
107
+ GeoJSON.polygon [ring], projection: projection, name: "neatline"
108
+ end
109
+
110
+ raise OptionParser::InvalidArgument, "inset covers map" if neatline.none?
111
+ raise OptionParser::InvalidArgument, "inset creates non-contiguous map" unless neatline.one?
112
+ new(archive, neatline: neatline, centre: centre, dimensions: dimensions, scale: scale, rotation: rotation).save
90
113
  end
91
114
 
92
115
  def self.load(archive)
93
- new archive, **YAML.load(archive.read "map.yml")
116
+ properties = YAML.load(archive.read "map.yml")
117
+ neatline = GeoJSON::Collection.load(archive.read "map.json")
118
+ new archive, neatline: neatline, **properties
119
+ rescue ArgumentError, YAML::Exception, GeoJSON::Error
120
+ raise NSWTopo::Archive::Invalid
94
121
  end
95
122
 
96
123
  def save
97
- tap { @archive.write "map.yml", YAML.dump(proj4: @projection.proj4, scale: @scale, centre: @centre, extents: @extents, rotation: @rotation, layers: @layers) }
124
+ tap do
125
+ write "map.json", @neatline.to_json
126
+ write "map.yml", YAML.dump(centre: @centre, dimensions: @dimensions, scale: @scale, rotation: @rotation, layers: @layers)
127
+ end
128
+ end
129
+
130
+ def self.from_svg(archive, svg_path)
131
+ xml = REXML::Document.new(svg_path.read)
132
+
133
+ creator_tool = xml.elements["svg/metadata/rdf:RDF/rdf:Description[@xmp:CreatorTool]/@xmp:CreatorTool"]&.value
134
+ version = Version[creator_tool]
135
+ raise "SVG nswtopo version too old: %s" % svg_path unless version >= MIN_VERSION
136
+ raise "SVG nswtopo version too new: %s" % svg_path unless version <= VERSION
137
+
138
+ /^0\s+0\s+(?<width>\S+)\s+(?<height>\S+)$/ =~ xml.elements["svg[@viewBox]/@viewBox"]&.value
139
+ width && xml.elements["svg[ @width='#{ width}mm']"] || raise(Version::Error)
140
+ height && xml.elements["svg[@height='#{height}mm']"] || raise(Version::Error)
141
+ dimensions = [width, height].map(&:to_f)
142
+
143
+ metadata = xml.elements["svg/metadata/nswtopo:map[@projection][@centre][@scale][@rotation]"] || raise(Version::Error)
144
+ projection = Projection.new metadata.attributes["projection"]
145
+ neatline = GeoJSON.polygon JSON.parse(metadata.attributes["neatline"]), projection: projection
146
+ centre = JSON.parse metadata.attributes["centre"]
147
+ scale = metadata.attributes["scale"].to_i
148
+ rotation = metadata.attributes["rotation"].to_f
149
+
150
+ new(archive, neatline: neatline, centre: centre, dimensions: dimensions, scale: scale, rotation: rotation).save.tap do |map|
151
+ map.write "map.svg", svg_path.read
152
+ end
153
+ rescue Version::Error, JSON::ParserError
154
+ raise "not an nswtopo SVG file: %s" % svg_path
155
+ rescue SystemCallError
156
+ raise "couldn't read file: %s" % svg_path
157
+ rescue REXML::ParseException
158
+ raise "unrecognised map file: %s" % svg_path
98
159
  end
99
160
 
100
161
  def layers
@@ -103,78 +164,63 @@ module NSWTopo
103
164
  end
104
165
  end
105
166
 
106
- def raster_dimensions_at(ppi: nil, resolution: nil)
107
- resolution ||= 0.0254 * @scale / ppi
108
- ppi ||= 0.0254 * @scale / resolution
109
- return (@extents / resolution).map(&:ceil), ppi, resolution
167
+ def neatline(mm: nil)
168
+ mm ? @neatline.buffer(mm).explode : @neatline
110
169
  end
111
170
 
112
- def wgs84_centre
113
- GeoJSON.point(@centre, projection: @projection).reproject_to_wgs84.coordinates
171
+ def cutline(mm: nil)
172
+ mm ? @cutline.buffer(mm).explode : @cutline
114
173
  end
115
174
 
116
- def bounding_box(mm: nil, metres: nil)
117
- margin = mm ? mm * 0.001 * @scale : metres ? metres : 0
118
- ring = @extents.map do |extent|
119
- [-0.5 * extent - margin, 0.5 * extent + margin]
120
- end.inject(&:product).map do |offset|
121
- @centre.plus offset.rotate_by_degrees(-@rotation)
122
- end.values_at(0,2,3,1,0)
123
- GeoJSON.polygon [ring], projection: projection
175
+ def te
176
+ [0, 0, *@dimensions]
124
177
  end
125
178
 
126
- def bounds(margin: {}, projection: nil)
127
- bounding_box(margin).yield_self do |bbox|
128
- projection ? bbox.reproject_to(projection) : bbox
129
- end.coordinates.first.transpose.map(&:minmax)
179
+ def to_mm(metres)
180
+ metres * 1000.0 / @scale
130
181
  end
131
182
 
132
- def projwin(projection)
133
- bounds(projection: projection).flatten.values_at(0,3,1,2)
183
+ def to_metres(mm)
184
+ mm * @scale / 1000.0
134
185
  end
135
186
 
136
- def write_world_file(path, resolution: nil, ppi: nil)
137
- resolution ||= 0.0254 * @scale / ppi
138
- top_left = bounding_box.coordinates[0][3]
139
- WorldFile.write top_left, resolution, -@rotation, path
187
+ def geotransform(resolution: nil, ppi: nil)
188
+ mm_per_px = ppi ? 25.4 / ppi : to_mm(resolution)
189
+ [0.0, mm_per_px, 0.0, @dimensions[1], 0.0, -mm_per_px]
140
190
  end
141
191
 
142
- def coords_to_mm(point)
143
- @affine.map do |row|
144
- row.dot [*point, 1.0]
192
+ def write_world_file(path, **opts)
193
+ ulx, mm_per_px, _, uly, _, _ = geotransform(**opts)
194
+ path.open("w") do |file|
195
+ file.puts mm_per_px, 0, 0, -mm_per_px
196
+ file.puts ulx + 0.5 * mm_per_px
197
+ file.puts uly - 0.5 * mm_per_px
145
198
  end
146
199
  end
147
200
 
148
- def get_raster_resolution(raster_path)
149
- metre_diagonal = bounding_box.coordinates.first.values_at(0, 2)
150
- pixel_diagonal = OS.gdaltransform "-i", "-t_srs", @projection, raster_path do |stdin|
151
- metre_diagonal.each do |point|
152
- stdin.puts point.join(?\s)
153
- end
154
- end.each_line.map do |line|
155
- line.split(?\s).take(2).map(&:to_f)
201
+ def write_pam_file(path, **opts)
202
+ REXML::Document.new("", raw: %w[SRS], attribute_quote: :quote).add_element("PAMDataset").tap do |pam|
203
+ pam.add_element("SRS", "dataAxisToSRSAxisMapping" => "1,2").add_text @projection.wkt2
204
+ pam.add_element("GeoTransform").add_text geotransform(**opts).join(?,)
205
+ path.write pam
156
206
  end
157
- metre_diagonal.distance / pixel_diagonal.distance
158
- rescue OS::Error
159
- raise "invalid raster"
160
207
  end
161
208
 
162
209
  def self.declination(longitude, latitude)
163
210
  today = Date.today
164
- query = { lat1: latitude.abs, lat1Hemisphere: latitude < 0 ? ?S : ?N, lon1: longitude.abs, lon1Hemisphere: longitude < 0 ? ?W : ?E, model: "WMM", startYear: today.year, startMonth: today.month, startDay: today.day, resultFormat: "xml" }
165
- uri = URI::HTTPS.build host: "www.ngdc.noaa.gov", path: "/geomag-web/calculators/calculateDeclination", query: URI.encode_www_form(query)
166
- xml = Net::HTTP.get uri
167
- text = REXML::Document.new(xml).elements["//declination"]&.text
168
- text ? text.to_f : raise
169
- rescue RuntimeError, SystemCallError, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError
211
+ query = { latd: latitude, lond: longitude, latm: 0, lonm: 0, lats: 0, lons: 0, elev: 0, year: today.year, month: today.month, day: today.day, Ein: "Dtrue" }
212
+ uri = URI::HTTPS.build host: "api.geomagnetism.ga.gov.au", path: "/agrf", query: URI.encode_www_form(query)
213
+ json = Net::HTTP.get uri
214
+ Float(JSON.parse(json).dig("magneticFields", "D").to_s.sub(/ .*/, ""))
215
+ rescue JSON::ParserError, ArgumentError, TypeError, SystemCallError, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError
170
216
  raise "couldn't get magnetic declination value"
171
217
  end
172
218
 
173
219
  def declination
174
- Map.declination *wgs84_centre
220
+ Map.declination *@centre
175
221
  end
176
222
 
177
- def add(*layers, after: nil, before: nil, replace: nil, overwrite: false)
223
+ def add(*layers, after: nil, before: nil, replace: nil, overwrite: false, strict: false)
178
224
  [%w[before after replace], [before, after, replace]].transpose.select(&:last).each do |option, name|
179
225
  next if self.layers.any? { |other| other.name == name }
180
226
  raise "no such layer: %s" % name
@@ -182,6 +228,7 @@ module NSWTopo
182
228
  raise OptionParser::AmbiguousOption, "can't specify --%s and --%s simultaneously" % options
183
229
  end
184
230
 
231
+ strict ||= layers.one?
185
232
  layers.inject [self.layers, false, replace || after, []] do |(layers, changed, follow, errors), layer|
186
233
  index = layers.index layer unless replace || after || before
187
234
  if overwrite || !layer.uptodate?
@@ -203,15 +250,28 @@ module NSWTopo
203
250
  index = layers.index { |other| (other <=> layer) > 0 } || -1
204
251
  end
205
252
  next layers.insert(index, layer), true, layer.name, errors
206
- rescue ArcGISServer::Error, RuntimeError => error
207
- log_warn ArcGISServer::Error === error ? "couldn't download layer: #{layer.name}" : error.message
253
+ rescue ArcGIS::Connection::Error => error
254
+ log_warn "couldn't download layer: #{layer.name}"
208
255
  next layers, changed, follow, errors << error
256
+ rescue RuntimeError => error
257
+ errors << error
258
+ break layers, changed, follow, errors if strict
259
+ log_warn error.message
260
+ next layers, changed, follow, errors
209
261
  end.tap do |ordered_layers, changed, follow, errors|
210
262
  if changed
211
263
  @layers.replace Hash[ordered_layers.map(&:pair)]
212
264
  replace ? delete(replace) : save
213
265
  end
214
- raise PartialFailureError, "failed to create %s" % [layers.one? ? "layer" : errors.one? ? "1 layer" : "#{errors.length} layers"] if errors.any?
266
+ case
267
+ when errors.none?
268
+ when strict
269
+ raise errors.first
270
+ when errors.one?
271
+ raise PartialFailureError, "failed to create layer"
272
+ else
273
+ raise PartialFailureError, "failed to create #{errors.length} layers"
274
+ end
215
275
  end
216
276
  end
217
277
 
@@ -231,37 +291,68 @@ module NSWTopo
231
291
  save
232
292
  end
233
293
 
234
- def info(empty: nil)
235
- StringIO.new.tap do |io|
236
- io.puts "%-11s 1:%i" % ["scale:", @scale]
237
- io.puts "%-11s %imm × %imm" % ["dimensions:", *@extents.times(1000.0 / @scale)]
238
- io.puts "%-11s %.1fkm × %.1fkm" % ["extent:", *@extents.times(0.001)]
239
- io.puts "%-11s %.1fkm²" % ["area:", @extents.inject(&:*) * 0.000001]
240
- io.puts "%-11s %.1f°" % ["rotation:", @rotation]
241
- layers.reject(&empty ? :nil? : :empty?).inject("layers:") do |heading, layer|
242
- io.puts "%-11s %s" % [heading, layer]
243
- nil
244
- end
245
- end.string
294
+ def move(name, before: nil, after: nil)
295
+ name, target = [name, before || after].map do |name|
296
+ Layer.sanitise name
297
+ end.each do |name|
298
+ raise OptionParser::InvalidArgument, "no such layer: #{name}" unless @layers.key? name
299
+ end
300
+ raise OptionParser::InvalidArgument, "layers must be different" if name == target
301
+ insert = [name, @layers.delete(name)]
302
+ @layers.each.with_object [] do |(name, layer), layers|
303
+ layers << insert if before && name == target
304
+ layers << [name, layer]
305
+ layers << insert if after && name == target
306
+ end.tap do |layers|
307
+ @layers.replace layers.to_h
308
+ end
309
+ save
310
+ end
311
+
312
+ def info(empty: nil, json: false, proj: false, wkt: false)
313
+ case
314
+ when json
315
+ bbox = @neatline.reproject_to_wgs84.first
316
+ bbox.properties.merge! dimensions: @dimensions, scale: @scale, rotation: @rotation, layers: layers.map(&:name)
317
+ JSON.pretty_generate bbox.to_h
318
+ when proj
319
+ OS.gdalsrsinfo("-o", "proj4", "--single-line", @projection)
320
+ when wkt
321
+ OS.gdalsrsinfo("-o", "wkt2", @projection).gsub(/\n\n+|\A\n+/, "")
322
+ else
323
+ area_km2 = @neatline.area * (0.000001 * @scale)**2
324
+ extents_km = @dimensions.times(0.000001 * @scale)
325
+ StringIO.new.tap do |io|
326
+ io.puts "%-11s 1:%i" % ["scale:", @scale]
327
+ io.puts "%-11s %imm × %imm" % ["dimensions:", *@dimensions.map(&:round)]
328
+ io.puts "%-11s %.1fkm × %.1fkm" % ["extent:", *extents_km]
329
+ io.puts "%-11s %.1fkm²" % ["area:", area_km2]
330
+ io.puts "%-11s %.1f°" % ["rotation:", @rotation]
331
+ layers.reject(&empty ? :nil? : :empty?).inject("layers:") do |heading, layer|
332
+ io.puts "%-11s %s" % [heading, layer]
333
+ nil
334
+ end
335
+ end.string
336
+ end
246
337
  end
247
338
  alias to_s info
248
339
 
249
- def render(*paths, worldfile: false, force: false, external: nil, **options)
340
+ def render(*paths, worldfile: false, force: false, background: nil, **options)
250
341
  @archive.delete "map.svg" if force
251
342
  Dir.mktmppath do |temp_dir|
252
343
  rasters = Hash.new do |rasters, opts|
253
344
  png_path = temp_dir / "raster.#{rasters.size}.png"
254
- pgw_path = temp_dir / "raster.#{rasters.size}.pgw"
255
- rasterise png_path, external: external, **opts
256
- write_world_file pgw_path, opts
345
+ pam_path = temp_dir / "raster.#{rasters.size}.png.aux.xml"
346
+ rasterise png_path, background: background, **opts
347
+ write_pam_file pam_path, **opts
257
348
  rasters[opts] = png_path
258
349
  end
259
350
  dithers = Hash.new do |dithers, opts|
260
351
  png_path = temp_dir / "dither.#{dithers.size}.png"
261
- pgw_path = temp_dir / "dither.#{dithers.size}.pgw"
352
+ pam_path = temp_dir / "dither.#{dithers.size}.png.aux.xml"
262
353
  FileUtils.cp rasters[opts], png_path
263
354
  dither png_path
264
- write_world_file pgw_path, opts
355
+ write_pam_file pam_path, **opts
265
356
  dithers[opts] = png_path
266
357
  end
267
358
 
@@ -269,7 +360,7 @@ module NSWTopo
269
360
  ext = path.extname.delete_prefix ?.
270
361
  name = path.basename(path.extname)
271
362
  out_path = temp_dir / "output.#{index}.#{ext}"
272
- send "render_#{ext}", out_path, name: name, external: external, **options do |dither: false, **opts|
363
+ send "render_#{ext}", out_path, name: name, background: background, **options do |dither: false, **opts|
273
364
  (dither ? dithers : rasters)[opts]
274
365
  end
275
366
  next out_path, path
@@ -279,6 +370,8 @@ module NSWTopo
279
370
  outputs.each do |out_path, path|
280
371
  FileUtils.cp out_path, path
281
372
  log_success "created %s" % path
373
+ rescue SystemCallError
374
+ raise "couldn't save #{path}"
282
375
  end
283
376
 
284
377
  paths.select do |path|
data/lib/nswtopo/os.rb CHANGED
@@ -6,54 +6,32 @@ module NSWTopo
6
6
  GDAL = %w[
7
7
  gdal_contour
8
8
  gdal_grid
9
- gdal_rasterize
10
9
  gdal_translate
11
- gdaladdo
12
10
  gdalbuildvrt
13
11
  gdaldem
14
- gdalenhance
15
12
  gdalinfo
16
13
  gdallocationinfo
17
14
  gdalmanage
18
- gdalserver
19
15
  gdalsrsinfo
20
- gdaltindex
21
16
  gdaltransform
22
17
  gdalwarp
23
- gnmanalyse
24
- gnmmanage
25
- nearblack
26
18
  ogr2ogr
27
19
  ogrinfo
28
- ogrlineref
29
- ogrtindex
30
- testepsg
31
- ]
32
- ImageMagick = %w[
33
- animate
34
- compare
35
- composite
36
- conjure
37
- convert
38
- display
39
- identify
40
- import
41
- mogrify
42
- montage
43
- stream
44
20
  ]
21
+ ImageMagick = %w[magick]
45
22
  SQLite3 = %w[sqlite3]
46
23
  PNGQuant = %w[pngquant]
47
24
  GIMP = %w[gimp]
48
25
  Zip = %w[zip]
49
26
  SevenZ = %w[7z]
27
+ ExifTool = %w[exiftool]
50
28
 
51
29
  extend self
52
30
 
53
- %w[GDAL ImageMagick SQLite3 PNGQuant GIMP Zip SevenZ].each do |package|
31
+ %w[GDAL ImageMagick SQLite3 PNGQuant GIMP Zip SevenZ ExifTool].each do |package|
54
32
  OS.const_get(package).each do |command|
55
- define_method command do |*args, &block|
56
- Open3.popen3 command, *args.map(&:to_s) do |stdin, stdout, stderr, thread|
33
+ define_method command do |*args, **opts, &block|
34
+ Open3.popen3 command, *args.map(&:to_s), **opts do |stdin, stdout, stderr, thread|
57
35
  thr_in = Thread.new do
58
36
  block.call(stdin) if block
59
37
  rescue Errno::EPIPE
@@ -0,0 +1,54 @@
1
+ module NSWTopo
2
+ module TiledWebMap
3
+ HALF, TILE_SIZE, DEFAULT_ZOOM = Math::PI * 6378137, 256, 16
4
+
5
+ def tiled_web_map(temp_dir, extension:, zoom: [DEFAULT_ZOOM], **options, &block)
6
+ web_mercator_bounds = @cutline.reproject_to(Projection.new("EPSG:3857")).bounds
7
+ wgs84_bounds = @cutline.reproject_to_wgs84.bounds
8
+
9
+ png_path = nil
10
+ max_zoom, min_zoom = *zoom.sort.reverse
11
+ max_zoom.downto(0).map do |zoom|
12
+ indices, ts = web_mercator_bounds.map do |lower, upper|
13
+ (2**zoom * (lower + HALF) / HALF / 2).floor ... (2**zoom * (upper + HALF) / HALF / 2).ceil
14
+ end.map do |indices|
15
+ [indices, indices.size * TILE_SIZE]
16
+ end.transpose
17
+ te = [*indices.map(&:begin), *indices.map(&:end)].map do |index|
18
+ index * 2 * HALF / 2**zoom - HALF
19
+ end
20
+ resolution = 2 * HALF / TILE_SIZE / 2**zoom
21
+ tif_path = temp_dir / "tile.#{zoom}.tif"
22
+ OpenStruct.new resolution: resolution, ts: ts, te: te, tif_path: tif_path, indices: indices, zoom: zoom
23
+ end.select do |level|
24
+ next true if level.zoom == max_zoom
25
+ next level.zoom >= min_zoom if min_zoom
26
+ !level.indices.all?(&:one?)
27
+ end.tap do |max_level, *|
28
+ png_path = yield(resolution: max_level.resolution)
29
+ end.tap do |levels|
30
+ log_update "#{extension}: creating zoom levels %s" % levels.map(&:zoom).minmax.uniq.join(?-)
31
+ end.each.concurrently do |level|
32
+ OS.gdalwarp "-t_srs", "EPSG:3857", "-ts", *level.ts, "-te", *level.te, "-r", "cubic", "-dstalpha", png_path, level.tif_path
33
+ end.flat_map do |level|
34
+ cols, rows = level.indices
35
+ [cols.each, rows.reverse_each].map(&:with_index).map(&:entries).inject(&:product).map do |(col, j), (row, i)|
36
+ row ^= 2**level.zoom - 1 if extension == "gemf"
37
+ path = temp_dir / "tile.#{level.zoom}.#{col}.#{row}.png"
38
+ args = ["-srcwin", j * TILE_SIZE, i * TILE_SIZE, TILE_SIZE, TILE_SIZE, level.tif_path, path]
39
+ OpenStruct.new zoom: level.zoom, row: row, col: col, path: path, args: args
40
+ end
41
+ end.tap do |tiles|
42
+ log_update "#{extension}: creating %i tiles" % tiles.length
43
+ end.each.concurrently do |tile|
44
+ OS.gdal_translate *tile.args
45
+ end.entries.tap do |tiles|
46
+ log_update "#{extension}: optimising %i tiles" % tiles.length
47
+ tiles.map(&:path).each.concurrent_groups do |paths|
48
+ dither *paths
49
+ rescue Dither::Missing
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,27 @@
1
+ module NSWTopo
2
+ class TreeIndenter
3
+ def initialize(items, parts = nil, &block)
4
+ @enum = Enumerator.new do |yielder|
5
+ next unless items
6
+ grouped = block ? block.(items) : items
7
+ grouped.each.with_index do |(item, group), index|
8
+ *new_parts, last_part = parts
9
+ case last_part
10
+ when "├─ " then new_parts << "│ "
11
+ when "└─ " then new_parts << " "
12
+ end
13
+ new_parts << case index
14
+ when grouped.size - 1 then "└─ "
15
+ else "├─ "
16
+ end if parts
17
+ yielder << [new_parts, item]
18
+ TreeIndenter.new(group, new_parts, &block).inject(yielder, &:<<)
19
+ end
20
+ end
21
+ end
22
+
23
+ extend Forwardable
24
+ include Enumerable
25
+ delegate :each => :@enum
26
+ end
27
+ end
@@ -1,4 +1,29 @@
1
1
  module NSWTopo
2
- VERSION = "2.0.0-beta1"
3
- MIN_VERSION = "2.0.0-beta1"
2
+ class Version
3
+ include Comparable
4
+ Error = Class.new StandardError
5
+
6
+ def self.[](creator_string)
7
+ /^nswtopo (?<digit_string>\d+(\.\d+(\.\d+)?)?)$/ =~ creator_string.to_s
8
+ digit_string ? new(digit_string) : raise(Error)
9
+ end
10
+
11
+ def creator_string
12
+ "nswtopo #{self}"
13
+ end
14
+
15
+ def initialize(digit_string)
16
+ @to_s = digit_string
17
+ @to_a = digit_string.split(?.).map(&:to_i)
18
+ end
19
+
20
+ attr_reader :to_s, :to_a
21
+
22
+ def <=>(other)
23
+ self.to_a <=> other.to_a
24
+ end
25
+ end
26
+
27
+ VERSION = Version["nswtopo 3.0"]
28
+ MIN_VERSION = Version["nswtopo 3.0"]
4
29
  end