nswtopo 2.0.0.pre.beta1 → 3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/COPYING +70 -83
  3. data/bin/nswtopo +227 -116
  4. data/docs/README.md +1 -12
  5. data/docs/add.md +1 -1
  6. data/docs/config.md +1 -1
  7. data/docs/contours.md +3 -1
  8. data/docs/init.md +8 -0
  9. data/docs/inspect.md +103 -0
  10. data/docs/move.md +9 -0
  11. data/docs/render.md +16 -7
  12. data/docs/scrape.md +67 -0
  13. data/docs/spot-heights.md +6 -2
  14. data/lib/nswtopo/archive.rb +50 -41
  15. data/lib/nswtopo/chrome.rb +227 -0
  16. data/lib/nswtopo/commands/add.rb +106 -0
  17. data/lib/nswtopo/commands/config.rb +38 -0
  18. data/lib/nswtopo/commands/inspect.rb +74 -0
  19. data/lib/nswtopo/commands/layers.rb +22 -0
  20. data/lib/nswtopo/commands/scrape.rb +79 -0
  21. data/lib/nswtopo/commands.rb +57 -0
  22. data/lib/nswtopo/dither.rb +5 -3
  23. data/lib/nswtopo/font.rb +46 -21
  24. data/lib/nswtopo/formats/gemf.rb +42 -0
  25. data/lib/nswtopo/formats/kmz.rb +26 -24
  26. data/lib/nswtopo/formats/mbtiles.rb +5 -41
  27. data/lib/nswtopo/formats/pdf.rb +82 -17
  28. data/lib/nswtopo/formats/svg.rb +114 -45
  29. data/lib/nswtopo/formats/svgz.rb +2 -2
  30. data/lib/nswtopo/formats/zip.rb +33 -23
  31. data/lib/nswtopo/formats.rb +77 -32
  32. data/lib/nswtopo/geometry/overlap.rb +1 -32
  33. data/lib/nswtopo/geometry/r_tree.rb +16 -10
  34. data/lib/nswtopo/geometry/segment.rb +3 -3
  35. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +5 -6
  36. data/lib/nswtopo/geometry/vector_sequence.rb +7 -6
  37. data/lib/nswtopo/gis/arcgis/connection.rb +56 -0
  38. data/lib/nswtopo/gis/arcgis/layer/map.rb +163 -0
  39. data/lib/nswtopo/gis/arcgis/layer/query.rb +87 -0
  40. data/lib/nswtopo/gis/arcgis/layer/renderer.rb +66 -0
  41. data/lib/nswtopo/gis/arcgis/layer/statistics.rb +15 -0
  42. data/lib/nswtopo/gis/arcgis/layer.rb +201 -0
  43. data/lib/nswtopo/gis/arcgis/service.rb +57 -0
  44. data/lib/nswtopo/gis/arcgis.rb +3 -0
  45. data/lib/nswtopo/gis/dem.rb +13 -12
  46. data/lib/nswtopo/gis/esri_hdr.rb +8 -2
  47. data/lib/nswtopo/gis/geojson/collection.rb +45 -21
  48. data/lib/nswtopo/gis/geojson/multi_line_string.rb +2 -24
  49. data/lib/nswtopo/gis/geojson/multi_polygon.rb +2 -53
  50. data/lib/nswtopo/gis/geojson/polygon.rb +15 -0
  51. data/lib/nswtopo/gis/geojson.rb +12 -3
  52. data/lib/nswtopo/gis/gps/kml.rb +25 -19
  53. data/lib/nswtopo/gis/gps.rb +2 -0
  54. data/lib/nswtopo/gis/projection.rb +35 -24
  55. data/lib/nswtopo/gis/shapefile.rb +89 -16
  56. data/lib/nswtopo/gis.rb +1 -2
  57. data/lib/nswtopo/helpers/array.rb +0 -11
  58. data/lib/nswtopo/helpers/colour.rb +34 -14
  59. data/lib/nswtopo/layer/arcgis_raster.rb +44 -48
  60. data/lib/nswtopo/layer/colour_mask.rb +5 -0
  61. data/lib/nswtopo/layer/contour.rb +35 -28
  62. data/lib/nswtopo/layer/control.rb +2 -7
  63. data/lib/nswtopo/layer/declination.rb +9 -9
  64. data/lib/nswtopo/layer/feature.rb +36 -22
  65. data/lib/nswtopo/layer/grid.rb +30 -27
  66. data/lib/nswtopo/layer/import.rb +1 -21
  67. data/lib/nswtopo/layer/labels/barrier.rb +39 -0
  68. data/lib/nswtopo/layer/labels.rb +551 -383
  69. data/lib/nswtopo/layer/mask_render.rb +37 -0
  70. data/lib/nswtopo/layer/overlay.rb +2 -2
  71. data/lib/nswtopo/layer/raster.rb +31 -41
  72. data/lib/nswtopo/layer/raster_import.rb +17 -0
  73. data/lib/nswtopo/layer/raster_render.rb +15 -0
  74. data/lib/nswtopo/layer/relief.rb +27 -95
  75. data/lib/nswtopo/layer/spot.rb +63 -62
  76. data/lib/nswtopo/layer/vector/cutout.rb +15 -0
  77. data/lib/nswtopo/layer/vector/knockout.rb +16 -0
  78. data/lib/nswtopo/layer/vector.rb +121 -89
  79. data/lib/nswtopo/layer/vegetation.rb +39 -34
  80. data/lib/nswtopo/layer.rb +30 -16
  81. data/lib/nswtopo/map.rb +202 -109
  82. data/lib/nswtopo/os.rb +5 -27
  83. data/lib/nswtopo/tiled_web_map.rb +54 -0
  84. data/lib/nswtopo/tree_indenter.rb +27 -0
  85. data/lib/nswtopo/version.rb +27 -2
  86. data/lib/nswtopo.rb +6 -199
  87. metadata +41 -22
  88. data/lib/nswtopo/font/chrome.rb +0 -59
  89. data/lib/nswtopo/font/generic.rb +0 -25
  90. data/lib/nswtopo/gis/arcgis_server/connection.rb +0 -52
  91. data/lib/nswtopo/gis/arcgis_server.rb +0 -155
  92. data/lib/nswtopo/gis/geojson/multi_point.rb +0 -12
  93. data/lib/nswtopo/gis/world_file.rb +0 -19
  94. data/lib/nswtopo/layer/labels/fence.rb +0 -20
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