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.
- checksums.yaml +4 -4
- data/COPYING +70 -83
- data/bin/nswtopo +227 -116
- data/docs/README.md +1 -12
- data/docs/add.md +1 -1
- data/docs/config.md +1 -1
- data/docs/contours.md +3 -1
- data/docs/init.md +8 -0
- data/docs/inspect.md +103 -0
- data/docs/move.md +9 -0
- data/docs/render.md +16 -7
- data/docs/scrape.md +67 -0
- data/docs/spot-heights.md +6 -2
- data/lib/nswtopo/archive.rb +50 -41
- data/lib/nswtopo/chrome.rb +227 -0
- data/lib/nswtopo/commands/add.rb +106 -0
- data/lib/nswtopo/commands/config.rb +38 -0
- data/lib/nswtopo/commands/inspect.rb +74 -0
- data/lib/nswtopo/commands/layers.rb +22 -0
- data/lib/nswtopo/commands/scrape.rb +79 -0
- data/lib/nswtopo/commands.rb +57 -0
- data/lib/nswtopo/dither.rb +5 -3
- data/lib/nswtopo/font.rb +46 -21
- data/lib/nswtopo/formats/gemf.rb +42 -0
- data/lib/nswtopo/formats/kmz.rb +26 -24
- data/lib/nswtopo/formats/mbtiles.rb +5 -41
- data/lib/nswtopo/formats/pdf.rb +82 -17
- data/lib/nswtopo/formats/svg.rb +114 -45
- data/lib/nswtopo/formats/svgz.rb +2 -2
- data/lib/nswtopo/formats/zip.rb +33 -23
- data/lib/nswtopo/formats.rb +77 -32
- data/lib/nswtopo/geometry/overlap.rb +1 -32
- data/lib/nswtopo/geometry/r_tree.rb +16 -10
- data/lib/nswtopo/geometry/segment.rb +3 -3
- data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +5 -6
- data/lib/nswtopo/geometry/vector_sequence.rb +7 -6
- data/lib/nswtopo/gis/arcgis/connection.rb +56 -0
- data/lib/nswtopo/gis/arcgis/layer/map.rb +163 -0
- data/lib/nswtopo/gis/arcgis/layer/query.rb +87 -0
- data/lib/nswtopo/gis/arcgis/layer/renderer.rb +66 -0
- data/lib/nswtopo/gis/arcgis/layer/statistics.rb +15 -0
- data/lib/nswtopo/gis/arcgis/layer.rb +201 -0
- data/lib/nswtopo/gis/arcgis/service.rb +57 -0
- data/lib/nswtopo/gis/arcgis.rb +3 -0
- data/lib/nswtopo/gis/dem.rb +13 -12
- data/lib/nswtopo/gis/esri_hdr.rb +8 -2
- data/lib/nswtopo/gis/geojson/collection.rb +45 -21
- data/lib/nswtopo/gis/geojson/multi_line_string.rb +2 -24
- data/lib/nswtopo/gis/geojson/multi_polygon.rb +2 -53
- data/lib/nswtopo/gis/geojson/polygon.rb +15 -0
- data/lib/nswtopo/gis/geojson.rb +12 -3
- data/lib/nswtopo/gis/gps/kml.rb +25 -19
- data/lib/nswtopo/gis/gps.rb +2 -0
- data/lib/nswtopo/gis/projection.rb +35 -24
- data/lib/nswtopo/gis/shapefile.rb +89 -16
- data/lib/nswtopo/gis.rb +1 -2
- data/lib/nswtopo/helpers/array.rb +0 -11
- data/lib/nswtopo/helpers/colour.rb +34 -14
- data/lib/nswtopo/layer/arcgis_raster.rb +44 -48
- data/lib/nswtopo/layer/colour_mask.rb +5 -0
- data/lib/nswtopo/layer/contour.rb +35 -28
- data/lib/nswtopo/layer/control.rb +2 -7
- data/lib/nswtopo/layer/declination.rb +9 -9
- data/lib/nswtopo/layer/feature.rb +36 -22
- data/lib/nswtopo/layer/grid.rb +30 -27
- data/lib/nswtopo/layer/import.rb +1 -21
- data/lib/nswtopo/layer/labels/barrier.rb +39 -0
- data/lib/nswtopo/layer/labels.rb +551 -383
- data/lib/nswtopo/layer/mask_render.rb +37 -0
- data/lib/nswtopo/layer/overlay.rb +2 -2
- data/lib/nswtopo/layer/raster.rb +31 -41
- data/lib/nswtopo/layer/raster_import.rb +17 -0
- data/lib/nswtopo/layer/raster_render.rb +15 -0
- data/lib/nswtopo/layer/relief.rb +27 -95
- data/lib/nswtopo/layer/spot.rb +63 -62
- data/lib/nswtopo/layer/vector/cutout.rb +15 -0
- data/lib/nswtopo/layer/vector/knockout.rb +16 -0
- data/lib/nswtopo/layer/vector.rb +121 -89
- data/lib/nswtopo/layer/vegetation.rb +39 -34
- data/lib/nswtopo/layer.rb +30 -16
- data/lib/nswtopo/map.rb +202 -109
- data/lib/nswtopo/os.rb +5 -27
- data/lib/nswtopo/tiled_web_map.rb +54 -0
- data/lib/nswtopo/tree_indenter.rb +27 -0
- data/lib/nswtopo/version.rb +27 -2
- data/lib/nswtopo.rb +6 -199
- metadata +41 -22
- data/lib/nswtopo/font/chrome.rb +0 -59
- data/lib/nswtopo/font/generic.rb +0 -25
- data/lib/nswtopo/gis/arcgis_server/connection.rb +0 -52
- data/lib/nswtopo/gis/arcgis_server.rb +0 -155
- data/lib/nswtopo/gis/geojson/multi_point.rb +0 -12
- data/lib/nswtopo/gis/world_file.rb +0 -19
- 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,
|
|
6
|
-
@archive, @
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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(*
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
66
|
+
local_centre.rotate_by_degrees! -rotation
|
|
72
67
|
end
|
|
73
68
|
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
107
|
-
|
|
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
|
|
113
|
-
|
|
171
|
+
def cutline(mm: nil)
|
|
172
|
+
mm ? @cutline.buffer(mm).explode : @cutline
|
|
114
173
|
end
|
|
115
174
|
|
|
116
|
-
def
|
|
117
|
-
|
|
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
|
|
127
|
-
|
|
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
|
|
133
|
-
|
|
183
|
+
def to_metres(mm)
|
|
184
|
+
mm * @scale / 1000.0
|
|
134
185
|
end
|
|
135
186
|
|
|
136
|
-
def
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 = {
|
|
165
|
-
uri = URI::HTTPS.build host: "
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
|
207
|
-
log_warn
|
|
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
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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,
|
|
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
|
-
|
|
255
|
-
rasterise png_path,
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
data/lib/nswtopo/version.rb
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
module NSWTopo
|
|
2
|
-
|
|
3
|
-
|
|
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
|