nswtopo 2.0.0.pre.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/COPYING +674 -0
  3. data/bin/nswtopo +430 -0
  4. data/docs/README.md +78 -0
  5. data/docs/add.md +49 -0
  6. data/docs/config.md +24 -0
  7. data/docs/contours.md +37 -0
  8. data/docs/controls.md +9 -0
  9. data/docs/declination.md +15 -0
  10. data/docs/delete.md +15 -0
  11. data/docs/grid.md +5 -0
  12. data/docs/info.md +5 -0
  13. data/docs/init.md +38 -0
  14. data/docs/layers.md +11 -0
  15. data/docs/overlay.md +37 -0
  16. data/docs/relief.md +22 -0
  17. data/docs/render.md +43 -0
  18. data/docs/spot-heights.md +23 -0
  19. data/lib/nswtopo/archive.rb +93 -0
  20. data/lib/nswtopo/avl_tree.rb +128 -0
  21. data/lib/nswtopo/config.rb +73 -0
  22. data/lib/nswtopo/dither.rb +31 -0
  23. data/lib/nswtopo/font/chrome.rb +59 -0
  24. data/lib/nswtopo/font/generic.rb +25 -0
  25. data/lib/nswtopo/font.rb +43 -0
  26. data/lib/nswtopo/formats/kmz.rb +149 -0
  27. data/lib/nswtopo/formats/mbtiles.rb +64 -0
  28. data/lib/nswtopo/formats/pdf.rb +31 -0
  29. data/lib/nswtopo/formats/svg.rb +69 -0
  30. data/lib/nswtopo/formats/svgz.rb +13 -0
  31. data/lib/nswtopo/formats/zip.rb +40 -0
  32. data/lib/nswtopo/formats.rb +76 -0
  33. data/lib/nswtopo/geometry/overlap.rb +78 -0
  34. data/lib/nswtopo/geometry/r_tree.rb +47 -0
  35. data/lib/nswtopo/geometry/segment.rb +27 -0
  36. data/lib/nswtopo/geometry/straight_skeleton/collapse.rb +21 -0
  37. data/lib/nswtopo/geometry/straight_skeleton/interior_node.rb +17 -0
  38. data/lib/nswtopo/geometry/straight_skeleton/node.rb +50 -0
  39. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +295 -0
  40. data/lib/nswtopo/geometry/straight_skeleton/split.rb +33 -0
  41. data/lib/nswtopo/geometry/straight_skeleton/vertex.rb +9 -0
  42. data/lib/nswtopo/geometry/straight_skeleton.rb +6 -0
  43. data/lib/nswtopo/geometry/vector.rb +91 -0
  44. data/lib/nswtopo/geometry/vector_sequence.rb +179 -0
  45. data/lib/nswtopo/geometry.rb +8 -0
  46. data/lib/nswtopo/gis/arcgis_server/connection.rb +52 -0
  47. data/lib/nswtopo/gis/arcgis_server.rb +155 -0
  48. data/lib/nswtopo/gis/dem.rb +70 -0
  49. data/lib/nswtopo/gis/esri_hdr.rb +77 -0
  50. data/lib/nswtopo/gis/gdal_glob.rb +41 -0
  51. data/lib/nswtopo/gis/geojson/collection.rb +94 -0
  52. data/lib/nswtopo/gis/geojson/line_string.rb +11 -0
  53. data/lib/nswtopo/gis/geojson/multi_line_string.rb +63 -0
  54. data/lib/nswtopo/gis/geojson/multi_point.rb +12 -0
  55. data/lib/nswtopo/gis/geojson/multi_polygon.rb +167 -0
  56. data/lib/nswtopo/gis/geojson/point.rb +9 -0
  57. data/lib/nswtopo/gis/geojson/polygon.rb +11 -0
  58. data/lib/nswtopo/gis/geojson.rb +89 -0
  59. data/lib/nswtopo/gis/gps/gpx.rb +22 -0
  60. data/lib/nswtopo/gis/gps/kml.rb +66 -0
  61. data/lib/nswtopo/gis/gps.rb +20 -0
  62. data/lib/nswtopo/gis/projection.rb +56 -0
  63. data/lib/nswtopo/gis/shapefile.rb +24 -0
  64. data/lib/nswtopo/gis/world_file.rb +19 -0
  65. data/lib/nswtopo/gis.rb +9 -0
  66. data/lib/nswtopo/help_formatter.rb +59 -0
  67. data/lib/nswtopo/helpers/array.rb +30 -0
  68. data/lib/nswtopo/helpers/colour.rb +176 -0
  69. data/lib/nswtopo/helpers/concurrently.rb +27 -0
  70. data/lib/nswtopo/helpers/dir.rb +7 -0
  71. data/lib/nswtopo/helpers/hash.rb +15 -0
  72. data/lib/nswtopo/helpers/tar_writer.rb +11 -0
  73. data/lib/nswtopo/helpers.rb +6 -0
  74. data/lib/nswtopo/layer/arcgis_raster.rb +73 -0
  75. data/lib/nswtopo/layer/contour.rb +233 -0
  76. data/lib/nswtopo/layer/control.rb +94 -0
  77. data/lib/nswtopo/layer/declination.rb +53 -0
  78. data/lib/nswtopo/layer/feature.rb +87 -0
  79. data/lib/nswtopo/layer/grid.rb +120 -0
  80. data/lib/nswtopo/layer/import.rb +25 -0
  81. data/lib/nswtopo/layer/labels/fence.rb +20 -0
  82. data/lib/nswtopo/layer/labels.rb +630 -0
  83. data/lib/nswtopo/layer/overlay.rb +53 -0
  84. data/lib/nswtopo/layer/raster.rb +63 -0
  85. data/lib/nswtopo/layer/relief.rb +143 -0
  86. data/lib/nswtopo/layer/spot.rb +171 -0
  87. data/lib/nswtopo/layer/vector.rb +263 -0
  88. data/lib/nswtopo/layer/vegetation.rb +73 -0
  89. data/lib/nswtopo/layer.rb +78 -0
  90. data/lib/nswtopo/log.rb +28 -0
  91. data/lib/nswtopo/map.rb +296 -0
  92. data/lib/nswtopo/os.rb +75 -0
  93. data/lib/nswtopo/safely.rb +13 -0
  94. data/lib/nswtopo/version.rb +4 -0
  95. data/lib/nswtopo/zip.rb +15 -0
  96. data/lib/nswtopo.rb +249 -0
  97. metadata +142 -0
@@ -0,0 +1,73 @@
1
+ module NSWTopo
2
+ module Vegetation
3
+ include Raster, GDALGlob
4
+ CREATE = %w[mapping contrast colour]
5
+
6
+ def get_raster(temp_dir)
7
+ txt_path = temp_dir / "source.txt"
8
+ vrt_path = temp_dir / "source.vrt"
9
+
10
+ min, max = minmax = @mapping&.values_at("min", "max")
11
+ low, high, factor = { "low" => 0, "high" => 100, "factor" => 0.0 }.merge(@contrast || {}).values_at "low", "high", "factor"
12
+ woody, nonwoody = { "woody" => "#A6F1A6", "non-woody" => "#FFFFFF" }.merge(@colour || {}).values_at("woody", "non-woody").map { |string| Colour.new string }
13
+
14
+ colour_table = (0..255).map do |index|
15
+ case
16
+ when minmax&.all?(Integer) && minmax.all?(0..255)
17
+ (100.0 * (index - min) / (max - min)).clamp(0.0, 100.0)
18
+ when @mapping&.keys&.all?(Integer)
19
+ @mapping.fetch(index, 0)
20
+ else raise "no vegetation colour mapping specified for #{name}"
21
+ end
22
+ end.map do |percent|
23
+ (Float(percent - low) / (high - low)).clamp(0.0, 1.0)
24
+ end.map do |x|
25
+ next x if factor.zero?
26
+ [x, 1.0].map do |x|
27
+ [x, 0.0].map do |x|
28
+ 1 / (1 + Math::exp(factor * (0.5 - x)))
29
+ end.inject(&:-)
30
+ end.inject(&:/) # sigmoid between 0..1
31
+ end.map do |x|
32
+ nonwoody.mix(woody, x)
33
+ end
34
+
35
+ Dir.chdir(@source ? @source.parent : Pathname.pwd) do
36
+ gdal_rasters @path
37
+ end.tap do |rasters|
38
+ raise "no vegetation data file specified" if rasters.none?
39
+ end.group_by do |path, info|
40
+ Projection.new info.dig("coordinateSystem", "wkt")
41
+ end.map.with_index do |(projection, rasters), index|
42
+ indexed_tif_path = temp_dir / "indexed.#{index}.tif"
43
+ indexed_vrt_path = temp_dir / "indexed.#{index}.vrt"
44
+ coloured_tif_path = temp_dir / "coloured.#{index}.tif"
45
+ tif_path = temp_dir / "output.#{index}.tif"
46
+
47
+ txt_path.write rasters.map(&:first).join(?\n)
48
+ OS.gdalbuildvrt "-overwrite", "-input_file_list", txt_path, vrt_path
49
+ OS.gdal_translate "-projwin", *@map.projwin(projection), "-r", "near", "-co", "TFW=YES", vrt_path, indexed_tif_path
50
+ OS.gdal_translate "-of", "VRT", indexed_tif_path, indexed_vrt_path
51
+
52
+ xml = REXML::Document.new indexed_vrt_path.read
53
+ raise "can't process vegetation data for #{@name}" unless xml.elements.each("/VRTDataset/VRTRasterBand/ColorTable", &:itself).one?
54
+ raise "can't process vegetation data for #{@name}" unless xml.elements.each("/VRTDataset/VRTRasterBand/ColorTable/Entry", &:itself).count == 256
55
+ xml.elements.collect("/VRTDataset/VRTRasterBand/ColorTable/Entry", &:itself).zip(colour_table) do |entry, colour|
56
+ entry.attributes["c1"], entry.attributes["c2"], entry.attributes["c3"], entry.attributes["c4"] = *colour.triplet, 255
57
+ end
58
+ xml.elements.each("/VRTDataset/VRTRasterBand/NoDataValue", &:remove)
59
+ indexed_vrt_path.write xml
60
+ OS.gdal_translate "-expand", "rgb", indexed_vrt_path, coloured_tif_path
61
+
62
+ OS.gdalwarp "-s_srs", projection, "-t_srs", @map.projection, "-r", "bilinear", coloured_tif_path, tif_path
63
+ next tif_path, Numeric === @resolution ? @resolution : @map.get_raster_resolution(tif_path)
64
+ end.transpose.tap do |tif_paths, resolutions|
65
+ @resolution = resolutions.min
66
+ txt_path.write tif_paths.join(?\n)
67
+ OS.gdalbuildvrt "-overwrite", "-input_file_list", txt_path, vrt_path
68
+ end
69
+
70
+ return @resolution, vrt_path
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,78 @@
1
+ require_relative 'layer/raster'
2
+ require_relative 'layer/vector'
3
+ require_relative 'layer/vegetation'
4
+ require_relative 'layer/import'
5
+ require_relative 'layer/arcgis_raster'
6
+ require_relative 'layer/feature'
7
+ require_relative 'layer/contour'
8
+ require_relative 'layer/spot'
9
+ require_relative 'layer/overlay'
10
+ require_relative 'layer/relief'
11
+ require_relative 'layer/grid'
12
+ require_relative 'layer/declination'
13
+ require_relative 'layer/control'
14
+ require_relative 'layer/labels'
15
+
16
+ module NSWTopo
17
+ class Layer
18
+ TYPES = Set[Vegetation, Import, ArcGISRaster, Feature, Contour, Spot, Overlay, Relief, Grid, Declination, Control, Labels]
19
+
20
+ def initialize(name, map, params)
21
+ @type = begin
22
+ NSWTopo.const_get params["type"]
23
+ rescue NameError, TypeError
24
+ end
25
+
26
+ raise "unrecognised layer type: %s" % params["type"].inspect unless TYPES === @type
27
+ extend @type
28
+
29
+ @params = @type.const_defined?(:DEFAULTS) ? @type.const_get(:DEFAULTS).transform_keys(&:to_s).merge(params) : params
30
+ @name, @map, @source, @path, @resolution = Layer.sanitise(name), map, @params.delete("source"), @params.delete("path"), @params.delete("resolution")
31
+
32
+ @type.const_get(:CREATE).map(&:to_s).each do |attr|
33
+ instance_variable_set ?@ + attr.tr_s(?-, ?_), @params.delete(attr)
34
+ end if @type.const_defined?(:CREATE)
35
+ end
36
+
37
+ attr_reader :name, :params
38
+ alias to_s name
39
+
40
+ def level
41
+ case
42
+ when Vegetation == @type then 0
43
+ when Import == @type then 1
44
+ when ArcGISRaster == @type then 1
45
+ when Feature == @type then 2
46
+ when Contour == @type then 2
47
+ when Spot == @type then 2
48
+ when Overlay == @type then 3
49
+ when Relief == @type then 4
50
+ when Grid == @type then 5
51
+ when Declination == @type then 6
52
+ when Control == @type then 7
53
+ when Labels == @type then 99
54
+ end
55
+ end
56
+
57
+ def <=>(other)
58
+ [self, other].map(&:level).inject(&:<=>)
59
+ end
60
+
61
+ def ==(other)
62
+ Layer === other && self.name == other.name
63
+ end
64
+
65
+ def uptodate?
66
+ mtimes = [@source&.mtime, @map.mtime(filename)]
67
+ mtimes.all? && mtimes.inject(&:<)
68
+ end
69
+
70
+ def pair
71
+ return name, params
72
+ end
73
+
74
+ def self.sanitise(name)
75
+ name&.tr_s '^_a-zA-Z0-9*\-', ?.
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,28 @@
1
+ module NSWTopo
2
+ module Log
3
+ SUCCESS = $stdout.tty? ? "\r\e[2K\e[32mnswtopo:\e[0m %s" : "nswtopo: %s"
4
+ FAILURE = $stderr.tty? ? "\r\e[2K\e[31mnswtopo:\e[0m %s" : "nswtopo: %s"
5
+ NEUTRAL = $stdout.tty? ? "\r\e[2Knswtopo: %s" : "nswtopo: %s"
6
+ UPDATE = "\r\e[2K%s"
7
+
8
+ def log_success(message)
9
+ puts SUCCESS % message
10
+ end
11
+
12
+ def log_neutral(message)
13
+ puts NEUTRAL % message
14
+ end
15
+
16
+ def log_update(message)
17
+ print UPDATE % message if $stdout.tty?
18
+ end
19
+
20
+ def log_warn(message)
21
+ warn FAILURE % message
22
+ end
23
+
24
+ def log_abort(message)
25
+ abort FAILURE % message
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,296 @@
1
+ module NSWTopo
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
12
+ end
13
+ attr_reader :projection, :scale, :centre, :extents, :rotation
14
+
15
+ extend Forwardable
16
+ delegate %i[write mtime read] => :@archive
17
+
18
+ def self.init(archive, scale: 25000, rotation: 0.0, bounds: nil, coords: nil, dimensions: nil, margins: nil)
19
+ wgs84_points = case
20
+ when coords && bounds
21
+ raise "can't specify both bounds file and map coordinates"
22
+ when coords
23
+ coords
24
+ when bounds
25
+ gps = GPS.load bounds
26
+ margins ||= [15, 15] unless dimensions || gps.polygons.any?
27
+ case
28
+ when gps.polygons.any?
29
+ gps.polygons.map(&:coordinates).flatten(1).inject(&:+)
30
+ when gps.linestrings.any?
31
+ gps.linestrings.map(&:coordinates).inject(&:+)
32
+ when gps.points.any?
33
+ gps.points.map(&:coordinates)
34
+ else
35
+ raise "no features found in %s" % bounds
36
+ end
37
+ else
38
+ raise "no bounds file or map coordinates specified"
39
+ end
40
+
41
+ wgs84_centre = wgs84_points.transpose.map(&:minmax).map(&:sum).times(0.5)
42
+ projection = Projection.azimuthal_equidistant *wgs84_centre
43
+
44
+ case rotation
45
+ when "auto"
46
+ 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)
49
+ rotation *= -180.0 / Math::PI
50
+ when "magnetic"
51
+ rotation = declination(*wgs84_centre)
52
+ else
53
+ raise "map rotation must be between ±45°" unless rotation.abs <= 45
54
+ end
55
+
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|
67
+ point.rotate_by_degrees rotation
68
+ end.transpose.map(&:minmax).map do |min, max|
69
+ [0.5 * (max + min), max - min]
70
+ end.transpose
71
+ centre.rotate_by_degrees! -rotation
72
+ end
73
+
74
+ wgs84_centre = GeoJSON.point(centre, projection: projection).reproject_to_wgs84.coordinates
75
+ projection = Projection.transverse_mercator *wgs84_centre
76
+
77
+ extents = extents.zip(margins).map do |extent, margin|
78
+ extent + 2 * margin * 0.001 * scale
79
+ end if margins
80
+
81
+ case
82
+ when extents.all?(&:positive?)
83
+ when coords
84
+ raise "not enough information to calculate map size – add more coordinates, or specify map dimensions or margins"
85
+ when bounds
86
+ raise "not enough information to calculate map size – check bounds file, or specify map dimensions or margins"
87
+ end
88
+
89
+ new(archive, proj4: projection.proj4, scale: scale, centre: [0, 0], extents: extents, rotation: rotation).save
90
+ end
91
+
92
+ def self.load(archive)
93
+ new archive, **YAML.load(archive.read "map.yml")
94
+ end
95
+
96
+ def save
97
+ tap { @archive.write "map.yml", YAML.dump(proj4: @projection.proj4, scale: @scale, centre: @centre, extents: @extents, rotation: @rotation, layers: @layers) }
98
+ end
99
+
100
+ def layers
101
+ @layers.map do |name, params|
102
+ Layer.new(name, self, params)
103
+ end
104
+ end
105
+
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
110
+ end
111
+
112
+ def wgs84_centre
113
+ GeoJSON.point(@centre, projection: @projection).reproject_to_wgs84.coordinates
114
+ end
115
+
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
124
+ end
125
+
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)
130
+ end
131
+
132
+ def projwin(projection)
133
+ bounds(projection: projection).flatten.values_at(0,3,1,2)
134
+ end
135
+
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
140
+ end
141
+
142
+ def coords_to_mm(point)
143
+ @affine.map do |row|
144
+ row.dot [*point, 1.0]
145
+ end
146
+ end
147
+
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)
156
+ end
157
+ metre_diagonal.distance / pixel_diagonal.distance
158
+ rescue OS::Error
159
+ raise "invalid raster"
160
+ end
161
+
162
+ def self.declination(longitude, latitude)
163
+ 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
170
+ raise "couldn't get magnetic declination value"
171
+ end
172
+
173
+ def declination
174
+ Map.declination *wgs84_centre
175
+ end
176
+
177
+ def add(*layers, after: nil, before: nil, replace: nil, overwrite: false)
178
+ [%w[before after replace], [before, after, replace]].transpose.select(&:last).each do |option, name|
179
+ next if self.layers.any? { |other| other.name == name }
180
+ raise "no such layer: %s" % name
181
+ end.map(&:first).combination(2).each do |options|
182
+ raise OptionParser::AmbiguousOption, "can't specify --%s and --%s simultaneously" % options
183
+ end
184
+
185
+ layers.inject [self.layers, false, replace || after, []] do |(layers, changed, follow, errors), layer|
186
+ index = layers.index layer unless replace || after || before
187
+ if overwrite || !layer.uptodate?
188
+ layer.create
189
+ log_success "%s layer: %s" % [layer.empty? ? "empty" : "added", layer.name]
190
+ else
191
+ log_neutral "kept existing layer: %s" % layer.name
192
+ next layers, changed, layer.name, errors if index
193
+ end
194
+ layers.delete layer
195
+ case
196
+ when index
197
+ when follow
198
+ index = layers.index { |other| other.name == follow }
199
+ index += 1
200
+ when before
201
+ index = layers.index { |other| other.name == before }
202
+ else
203
+ index = layers.index { |other| (other <=> layer) > 0 } || -1
204
+ end
205
+ 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
208
+ next layers, changed, follow, errors << error
209
+ end.tap do |ordered_layers, changed, follow, errors|
210
+ if changed
211
+ @layers.replace Hash[ordered_layers.map(&:pair)]
212
+ replace ? delete(replace) : save
213
+ end
214
+ raise PartialFailureError, "failed to create %s" % [layers.one? ? "layer" : errors.one? ? "1 layer" : "#{errors.length} layers"] if errors.any?
215
+ end
216
+ end
217
+
218
+ def delete(*names)
219
+ raise OptionParser::MissingArgument, "no layers specified" unless names.any?
220
+ names.inject Set[] do |matched, name|
221
+ matches = @layers.keys.grep(name)
222
+ raise "no such layer: #{name}" if String === name && matches.none?
223
+ matched.merge matches
224
+ end.tap do |names|
225
+ raise "no matching layers found" unless names.any?
226
+ end.each do |name|
227
+ params = @layers.delete name
228
+ @archive.delete Layer.new(name, self, params).filename
229
+ log_success "deleted layer: %s" % name
230
+ end
231
+ save
232
+ end
233
+
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
246
+ end
247
+ alias to_s info
248
+
249
+ def render(*paths, worldfile: false, force: false, external: nil, **options)
250
+ @archive.delete "map.svg" if force
251
+ Dir.mktmppath do |temp_dir|
252
+ rasters = Hash.new do |rasters, opts|
253
+ 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
257
+ rasters[opts] = png_path
258
+ end
259
+ dithers = Hash.new do |dithers, opts|
260
+ png_path = temp_dir / "dither.#{dithers.size}.png"
261
+ pgw_path = temp_dir / "dither.#{dithers.size}.pgw"
262
+ FileUtils.cp rasters[opts], png_path
263
+ dither png_path
264
+ write_world_file pgw_path, opts
265
+ dithers[opts] = png_path
266
+ end
267
+
268
+ outputs = paths.map.with_index do |path, index|
269
+ ext = path.extname.delete_prefix ?.
270
+ name = path.basename(path.extname)
271
+ out_path = temp_dir / "output.#{index}.#{ext}"
272
+ send "render_#{ext}", out_path, name: name, external: external, **options do |dither: false, **opts|
273
+ (dither ? dithers : rasters)[opts]
274
+ end
275
+ next out_path, path
276
+ end
277
+
278
+ safely "saving, please wait..." do
279
+ outputs.each do |out_path, path|
280
+ FileUtils.cp out_path, path
281
+ log_success "created %s" % path
282
+ end
283
+
284
+ paths.select do |path|
285
+ %w[.png .tif .jpg].include? path.extname
286
+ end.group_by do |path|
287
+ path.parent / path.basename(path.extname)
288
+ end.keys.each do |base|
289
+ write_world_file Pathname("#{base}.wld"), ppi: options.fetch(:ppi, Formats::PPI)
290
+ Pathname("#{base}.prj").write "#{@projection}\n"
291
+ end if worldfile
292
+ end
293
+ end
294
+ end
295
+ end
296
+ end
data/lib/nswtopo/os.rb ADDED
@@ -0,0 +1,75 @@
1
+ module NSWTopo
2
+ module OS
3
+ Error = Class.new RuntimeError
4
+ Missing = Class.new RuntimeError
5
+
6
+ GDAL = %w[
7
+ gdal_contour
8
+ gdal_grid
9
+ gdal_rasterize
10
+ gdal_translate
11
+ gdaladdo
12
+ gdalbuildvrt
13
+ gdaldem
14
+ gdalenhance
15
+ gdalinfo
16
+ gdallocationinfo
17
+ gdalmanage
18
+ gdalserver
19
+ gdalsrsinfo
20
+ gdaltindex
21
+ gdaltransform
22
+ gdalwarp
23
+ gnmanalyse
24
+ gnmmanage
25
+ nearblack
26
+ ogr2ogr
27
+ 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
+ ]
45
+ SQLite3 = %w[sqlite3]
46
+ PNGQuant = %w[pngquant]
47
+ GIMP = %w[gimp]
48
+ Zip = %w[zip]
49
+ SevenZ = %w[7z]
50
+
51
+ extend self
52
+
53
+ %w[GDAL ImageMagick SQLite3 PNGQuant GIMP Zip SevenZ].each do |package|
54
+ 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|
57
+ thr_in = Thread.new do
58
+ block.call(stdin) if block
59
+ rescue Errno::EPIPE
60
+ ensure
61
+ stdin.close
62
+ end
63
+ thr_out = Thread.new { stdout.read }
64
+ thr_err = Thread.new { stderr.read }
65
+ [thr_in, thr_out, thr_err].each(&:join)
66
+ raise Error, "#{command}: #{thr_err.value.empty? ? thr_out.value : thr_err.value}" unless thread.value.success?
67
+ thr_out.value
68
+ end
69
+ rescue Errno::ENOENT
70
+ raise Missing, "#{package} not installed"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,13 @@
1
+ module NSWTopo
2
+ module Safely
3
+ include Log
4
+ def safely(message)
5
+ yield
6
+ rescue Interrupt => interrupt
7
+ log_warn message
8
+ retry
9
+ ensure
10
+ raise interrupt if interrupt
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,4 @@
1
+ module NSWTopo
2
+ VERSION = "2.0.0-beta1"
3
+ MIN_VERSION = "2.0.0-beta1"
4
+ end
@@ -0,0 +1,15 @@
1
+ module NSWTopo
2
+ module Zip
3
+ def zip(directory, archive)
4
+ Enumerator.new do |yielder|
5
+ yielder << ->(dir) { OS.zip "-r", archive.expand_path, *Pathname.glob('*') }
6
+ yielder << ->(dir) { OS.send "7z", "a", "-tzip", "-r", archive.expand_path, *Pathname.glob('*') }
7
+ raise "no zip utility installed"
8
+ end.each do |zip|
9
+ Dir.chdir(directory, &zip)
10
+ break
11
+ rescue OS::Missing
12
+ end
13
+ end
14
+ end
15
+ end