nswtopo 2.0.0 → 3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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 +39 -20
- 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
@@ -0,0 +1,106 @@
|
|
1
|
+
module NSWTopo
|
2
|
+
def add(archive, *layers, after: nil, before: nil, replace: nil, overwrite: false, strict: false, **options)
|
3
|
+
create_options = {
|
4
|
+
after: Layer.sanitise(after),
|
5
|
+
before: Layer.sanitise(before),
|
6
|
+
replace: Layer.sanitise(replace),
|
7
|
+
overwrite: overwrite,
|
8
|
+
strict: strict
|
9
|
+
}
|
10
|
+
map = Map.load archive
|
11
|
+
|
12
|
+
Enumerator.new do |yielder|
|
13
|
+
while layers.any?
|
14
|
+
layer, basedir = layers.shift
|
15
|
+
path = Pathname(layer).expand_path(*basedir)
|
16
|
+
case layer
|
17
|
+
when /^controls\.(gpx|kml)$/i
|
18
|
+
yielder << [path.basename(path.extname).to_s, "type" => "Control", "path" => path]
|
19
|
+
when /\.(gpx|kml)$/i
|
20
|
+
yielder << [path.basename(path.extname).to_s, "type" => "Overlay", "path" => path]
|
21
|
+
when /\.(tiff?|png|jpg)$/i
|
22
|
+
yielder << [path.basename(path.extname).to_s, "type" => "Import", "path" => path]
|
23
|
+
when "contours"
|
24
|
+
yielder << [layer, "type" => "Contour"]
|
25
|
+
when "spot-heights"
|
26
|
+
yielder << [layer, "type" => "Spot"]
|
27
|
+
when "relief"
|
28
|
+
yielder << [layer, "type" => "Relief"]
|
29
|
+
when "grid"
|
30
|
+
yielder << [layer, "type" => "Grid"]
|
31
|
+
when "declination"
|
32
|
+
yielder << [layer, "type" => "Declination"]
|
33
|
+
when "controls"
|
34
|
+
yielder << [layer, "type" => "Control"]
|
35
|
+
when /\.yml$/i
|
36
|
+
basedir ||= path.parent
|
37
|
+
raise "couldn't find '#{layer}'" unless path.file?
|
38
|
+
case contents = YAML.load(path.read)
|
39
|
+
when Array
|
40
|
+
contents.reverse.map do |item|
|
41
|
+
Pathname(item.to_s)
|
42
|
+
end.each do |relative_path|
|
43
|
+
raise "#{relative_path} is not a relative path" unless relative_path.relative?
|
44
|
+
layers.prepend [Pathname(relative_path).expand_path(path.parent).relative_path_from(basedir).to_s, basedir]
|
45
|
+
end
|
46
|
+
when Hash
|
47
|
+
name = path.sub_ext("").relative_path_from(basedir).descend.map(&:basename).join(?.)
|
48
|
+
yielder << [name, contents.merge("source" => path)]
|
49
|
+
else
|
50
|
+
raise "couldn't parse #{path}"
|
51
|
+
end
|
52
|
+
else
|
53
|
+
path = Pathname("#{layer}.yml")
|
54
|
+
raise "#{layer} is not a relative path" unless path.relative?
|
55
|
+
basedir ||= layer_dirs.find do |root|
|
56
|
+
path.expand_path(root).file?
|
57
|
+
end
|
58
|
+
layers.prepend [path.to_s, basedir]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
rescue YAML::Exception
|
62
|
+
raise "couldn't parse #{path}"
|
63
|
+
end.map do |name, params|
|
64
|
+
params.merge! options.transform_keys(&:to_s)
|
65
|
+
params.merge! Config[name] if Config[name]
|
66
|
+
Layer.new(name, map, params)
|
67
|
+
end.tap do |layers|
|
68
|
+
raise OptionParser::MissingArgument, "no layers specified" unless layers.any?
|
69
|
+
unless layers.one?
|
70
|
+
raise OptionParser::InvalidArgument, "can't specify opacity when adding multiple layers" if options[:opacity]
|
71
|
+
raise OptionParser::InvalidArgument, "can't specify data path when adding multiple layers" if options[:path]
|
72
|
+
end
|
73
|
+
map.add *layers, **create_options
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def contours(archive, dem_path, **options)
|
78
|
+
add archive, "contours", **options, path: Pathname(dem_path)
|
79
|
+
end
|
80
|
+
|
81
|
+
def spot_heights(archive, dem_path, **options)
|
82
|
+
add archive, "spot-heights", **options, path: Pathname(dem_path)
|
83
|
+
end
|
84
|
+
|
85
|
+
def relief(archive, dem_path, **options)
|
86
|
+
add archive, "relief", **options, path: Pathname(dem_path)
|
87
|
+
end
|
88
|
+
|
89
|
+
def grid(archive, **options)
|
90
|
+
add archive, "grid", **options
|
91
|
+
end
|
92
|
+
|
93
|
+
def declination(archive, **options)
|
94
|
+
add archive, "declination", **options
|
95
|
+
end
|
96
|
+
|
97
|
+
def controls(archive, gps_path, **options)
|
98
|
+
raise OptionParser::InvalidArgument, gps_path unless gps_path =~ /\.(gpx|kml)$/i
|
99
|
+
add archive, "controls", **options, path: Pathname(gps_path)
|
100
|
+
end
|
101
|
+
|
102
|
+
def overlay(archive, gps_path, **options)
|
103
|
+
raise OptionParser::InvalidArgument, gps_path unless gps_path =~ /\.(gpx|kml)$/i
|
104
|
+
add archive, gps_path, **options, path: Pathname(gps_path)
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module NSWTopo
|
2
|
+
def config(layer = nil, **options)
|
3
|
+
path, resolution = options[:path], options[:resolution]
|
4
|
+
layer = Layer.sanitise layer
|
5
|
+
|
6
|
+
case
|
7
|
+
when !layer
|
8
|
+
raise OptionParser::InvalidArgument, "no layer name specified for path" if path
|
9
|
+
raise OptionParser::InvalidArgument, "no layer name specified for resolution" if resolution
|
10
|
+
when path || resolution
|
11
|
+
Config.store layer, "path", path.to_s if path
|
12
|
+
Config.store layer, "resolution", resolution if resolution
|
13
|
+
end
|
14
|
+
|
15
|
+
options.each do |key, value|
|
16
|
+
case key
|
17
|
+
when :chrome
|
18
|
+
raise "chrome path is not an executable" unless value.executable? && !value.directory?
|
19
|
+
Config.store key.to_s, value.to_s
|
20
|
+
when :"layer-dir"
|
21
|
+
raise "not a directory: %s" % value unless value.directory?
|
22
|
+
Config.store key.to_s, value.to_s
|
23
|
+
when *%i[labelling debug gpu versioning zlib-level knockout]
|
24
|
+
Config.store key.to_s, value
|
25
|
+
when :delete
|
26
|
+
Config.delete *layer, value
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
if options.empty?
|
31
|
+
puts Config.to_str.each_line.drop(1)
|
32
|
+
log_neutral "no configuration yet" if Config.empty?
|
33
|
+
else
|
34
|
+
Config.save
|
35
|
+
log_success "configuration updated"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module NSWTopo
|
2
|
+
def inspect(url_or_path, layer: nil, coords: nil, codes: nil, countwise: nil, **options)
|
3
|
+
options[:geometry] = GeoJSON.multipoint(coords).bbox if coords
|
4
|
+
|
5
|
+
case url_or_path
|
6
|
+
when ArcGIS::Service
|
7
|
+
source = ArcGIS::Service.new(url_or_path)
|
8
|
+
when Shapefile::Source
|
9
|
+
raise OptionParser::InvalidOption, "--id only applies to ArcGIS layers" if options[:id]
|
10
|
+
raise OptionParser::InvalidOption, "--decode only applies to ArcGIS layers" if options[:decode]
|
11
|
+
raise OptionParser::InvalidOption, "--codes only applies to ArcGIS layers" if codes
|
12
|
+
source = Shapefile::Source.new(url_or_path)
|
13
|
+
layer ||= source.only_layer
|
14
|
+
else
|
15
|
+
raise OptionParser::InvalidArgument, url_or_path
|
16
|
+
end
|
17
|
+
layer = source.layer(layer: layer, **options)
|
18
|
+
|
19
|
+
case
|
20
|
+
when codes
|
21
|
+
TreeIndenter.new(layer.codes) do |level|
|
22
|
+
level.map do |key, values|
|
23
|
+
case key
|
24
|
+
when Array
|
25
|
+
code, value = key
|
26
|
+
display_value = value.nil? || /[\t\n\r]/ === value ? value.inspect : value
|
27
|
+
["#{code} → #{display_value}", values]
|
28
|
+
else
|
29
|
+
["#{key}:", values]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end.each do |indents, info|
|
33
|
+
puts indents.join << info
|
34
|
+
end
|
35
|
+
|
36
|
+
when fields = options[:fields]
|
37
|
+
template = "%%%is │ %%%is │ %%s"
|
38
|
+
TreeIndenter.new(layer.counts) do |counts|
|
39
|
+
counts.group_by do |attributes, count|
|
40
|
+
attributes.shift
|
41
|
+
end.entries.select(&:first).map do |(name, value), counts|
|
42
|
+
[[name, counts.sum(&:last), value], counts]
|
43
|
+
end.sort do |((name1, count1, value1), counts1), ((name2, count2, value2), counts2)|
|
44
|
+
next count2 <=> count1 if countwise
|
45
|
+
value1 && value2 ? value1 <=> value2 : value1 ? 1 : value2 ? -1 : 0
|
46
|
+
end
|
47
|
+
end.map do |indents, (name, count, value)|
|
48
|
+
next name, count.to_s, indents.join << (value.nil? || /[\t\n\r]/ === value ? value.inspect : value.to_s)
|
49
|
+
end.transpose.tap do |names, counts, lines|
|
50
|
+
template %= [names.map(&:size).max, counts.map(&:size).max] if names
|
51
|
+
end.transpose.each do |row|
|
52
|
+
puts template % row
|
53
|
+
end
|
54
|
+
|
55
|
+
else
|
56
|
+
TreeIndenter.new(layer.info) do |hash|
|
57
|
+
hash.map do |key, value|
|
58
|
+
Hash === value ? ["#{key}:", value] : "#{key}: #{value}"
|
59
|
+
end
|
60
|
+
end.each do |indents, info|
|
61
|
+
puts indents.join << info
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
rescue ArcGIS::Layer::NoLayerError, Shapefile::Layer::NoLayerError => error
|
66
|
+
raise OptionParser::MissingArgument, error.message if codes || countwise || options.any?
|
67
|
+
puts "layers:"
|
68
|
+
TreeIndenter.new(source.layer_info, []).each do |indents, info|
|
69
|
+
puts indents.join << info
|
70
|
+
end
|
71
|
+
rescue ArcGIS::Renderer::TooManyFieldsError
|
72
|
+
raise OptionParser::InvalidOption, "use less fields with --fields"
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module NSWTopo
|
2
|
+
def layers(state: nil)
|
3
|
+
paths = layer_dirs.grep_v(Pathname.pwd).flat_map do |directory|
|
4
|
+
Array(state).inject(directory, &:/).glob("*")
|
5
|
+
end.sort
|
6
|
+
log_warn "no layers installed" if paths.none?
|
7
|
+
|
8
|
+
TreeIndenter.new(paths) do |paths|
|
9
|
+
paths.map do |path|
|
10
|
+
case
|
11
|
+
when path.glob("**/*.yml").any?
|
12
|
+
[path.basename.sub_ext(""), path.children.sort]
|
13
|
+
when path.sub_ext("").directory?
|
14
|
+
when path.extname == ".yml"
|
15
|
+
path.basename.sub_ext("")
|
16
|
+
end
|
17
|
+
end.compact
|
18
|
+
end.each do |indents, name|
|
19
|
+
puts [*indents, name].join
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module NSWTopo
|
2
|
+
def scrape(url, path, coords: nil, name: nil, epsg: nil, paginate: nil, concat: nil, **options)
|
3
|
+
flags = %w[-skipfailures]
|
4
|
+
flags += %W[-t_srs epsg:#{epsg}] if epsg
|
5
|
+
flags += %W[-nln #{name}] if name
|
6
|
+
|
7
|
+
format_flags = case path.to_s
|
8
|
+
when Shapefile::Source then %w[-update -overwrite]
|
9
|
+
when /\.sqlite3?$/ then %w[-f SQLite -dsco SPATIALITE=YES]
|
10
|
+
when /\.db$/ then %w[-f SQLite -dsco SPATIALITE=YES]
|
11
|
+
when /\.gpkg$/ then %w[-f GPKG]
|
12
|
+
when /\.tab$/ then ["-f", "MapInfo File"]
|
13
|
+
else ["-f", "ESRI Shapefile", "-lco", "ENCODING=UTF-8"]
|
14
|
+
end
|
15
|
+
|
16
|
+
options.merge! case path.to_s
|
17
|
+
when /\.sqlite3?$/ then { mixed: concat, launder: true }
|
18
|
+
when /\.db$/ then { mixed: concat, launder: true }
|
19
|
+
when /\.gpkg$/ then { mixed: concat, launder: true }
|
20
|
+
when /\.tab$/ then { }
|
21
|
+
else { truncate: 10 }
|
22
|
+
end
|
23
|
+
|
24
|
+
options[:geometry] = GeoJSON.multipoint(coords).bbox if coords
|
25
|
+
|
26
|
+
log_update "nswtopo: contacting server"
|
27
|
+
layer = ArcGIS::Service.new(url).layer(**options)
|
28
|
+
|
29
|
+
queue = Queue.new
|
30
|
+
thread = Thread.new do
|
31
|
+
while page = queue.pop
|
32
|
+
*, status = Open3.capture3 *%W[ogr2ogr #{path} /vsistdin/], *flags, *format_flags, stdin_data: page.to_json
|
33
|
+
format_flags = %w[-update -append]
|
34
|
+
queue.close unless status.success?
|
35
|
+
end
|
36
|
+
status
|
37
|
+
end
|
38
|
+
|
39
|
+
total_features, percent = "%i feature%s", "%%.%if%%%%"
|
40
|
+
Enumerator.new do |yielder|
|
41
|
+
hold, ok, count = [], nil, 0
|
42
|
+
layer.paged(per_page: paginate).tap do
|
43
|
+
total_features %= [layer.count, (?s unless layer.count == 1)]
|
44
|
+
percent %= layer.count < 1000 ? 0 : layer.count < 10000 ? 1 : 2
|
45
|
+
log_update "nswtopo: retrieving #{total_features}"
|
46
|
+
end.each do |page|
|
47
|
+
log_update "nswtopo: retrieving #{percent} of #{total_features}" % [100.0 * (count += page.count) / layer.count]
|
48
|
+
next hold << page if concat
|
49
|
+
next yielder << page if ok
|
50
|
+
next hold << page if page.all? do |feature|
|
51
|
+
feature.properties.values.any?(&:nil?)
|
52
|
+
end
|
53
|
+
yielder << page
|
54
|
+
ok = true
|
55
|
+
end
|
56
|
+
next hold.inject(yielder, &:<<) if ok && !concat
|
57
|
+
next yielder << hold.inject(&:merge!) if hold.any?
|
58
|
+
end.inject(queue) do |queue, page|
|
59
|
+
queue << page
|
60
|
+
rescue ClosedQueueError
|
61
|
+
break queue
|
62
|
+
end.close
|
63
|
+
|
64
|
+
log_update "nswtop: saving #{total_features}"
|
65
|
+
raise "error while saving features" unless thread.value&.success?
|
66
|
+
log_success "saved #{total_features}"
|
67
|
+
|
68
|
+
rescue ArcGIS::Layer::NoLayerError
|
69
|
+
raise OptionParser::InvalidArgument, "specify an ArcGIS layer in URL or with --layer"
|
70
|
+
rescue ArcGIS::Map::NoUniqueFieldError
|
71
|
+
raise OptionParser::InvalidOption, "--unique required for this layer"
|
72
|
+
rescue ArcGIS::Renderer::NoGeometryError
|
73
|
+
raise OptionParser::InvalidOption, "--coords not available for this layer"
|
74
|
+
rescue ArcGIS::Query::UniqueFieldError
|
75
|
+
raise OptionParser::InvalidOption, "--unique not available for this layer"
|
76
|
+
rescue ArcGIS::Service::InvalidURLError
|
77
|
+
raise OptionParser::InvalidArgument, url
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require_relative 'commands/add'
|
2
|
+
require_relative 'commands/layers'
|
3
|
+
require_relative 'commands/config'
|
4
|
+
require_relative 'commands/scrape'
|
5
|
+
require_relative 'commands/inspect'
|
6
|
+
|
7
|
+
module NSWTopo
|
8
|
+
def init(archive, **options)
|
9
|
+
puts Map.init(archive, **options)
|
10
|
+
end
|
11
|
+
|
12
|
+
def info(archive, **options)
|
13
|
+
raise OptionParser::InvalidArgument, "one output option only" if options.slice(:json, :proj).length > 1
|
14
|
+
puts Map.load(archive).info(**options)
|
15
|
+
end
|
16
|
+
|
17
|
+
def delete(archive, *names, **options)
|
18
|
+
map = Map.load archive
|
19
|
+
names.map do |name|
|
20
|
+
Layer.sanitise name
|
21
|
+
end.uniq.map do |name|
|
22
|
+
name[?*] ? %r[^#{name.gsub(?., '\.').gsub(?*, '.*')}$] : name
|
23
|
+
end.tap do |names|
|
24
|
+
map.delete *names
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def move(archive, name, **options)
|
29
|
+
raise OptionParser::InvalidArgument, "only one of --before and --after allowed" if options[:after] && options[:before]
|
30
|
+
raise OptionParser::MissingArgument, "--before or --after required" unless options[:after] || options[:before]
|
31
|
+
Map.load(archive).move(name, **options)
|
32
|
+
end
|
33
|
+
|
34
|
+
def render(archive, basename, *formats, overwrite: false, svg_path: nil, **options)
|
35
|
+
case
|
36
|
+
when formats.any?
|
37
|
+
when svg_path
|
38
|
+
raise OptionParser::MissingArgument, "no output format specified"
|
39
|
+
else
|
40
|
+
formats << "svg"
|
41
|
+
end
|
42
|
+
|
43
|
+
formats.map do |format|
|
44
|
+
Pathname(Formats === format ? "#{basename}.#{format}" : format)
|
45
|
+
end.uniq.each do |path|
|
46
|
+
format = path.extname.delete_prefix(?.)
|
47
|
+
raise "unrecognised format: #{path}" if format.empty?
|
48
|
+
raise "unrecognised format: #{format}" unless Formats === format
|
49
|
+
raise "already a directory: #{path}" if path.directory?
|
50
|
+
raise "file already exists: #{path}" if path.exist? && !overwrite
|
51
|
+
raise "no such directory: #{path.parent}" unless path.parent.directory?
|
52
|
+
end.tap do |paths|
|
53
|
+
map = svg_path ? Map.from_svg(archive, svg_path) : Map.load(archive)
|
54
|
+
map.render *paths, **options
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
data/lib/nswtopo/dither.rb
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
module NSWTopo
|
2
2
|
module Dither
|
3
|
+
Missing = Class.new RuntimeError
|
4
|
+
|
3
5
|
def dither(*png_paths)
|
4
6
|
Enumerator.new do |yielder|
|
5
|
-
yielder << -> { OS.pngquant
|
7
|
+
yielder << -> { OS.pngquant *%w[--quiet --force --ext .png --speed 1 --nofs], *png_paths }
|
6
8
|
gimp_script = <<~EOF
|
7
9
|
(map
|
8
10
|
(lambda (path)
|
@@ -19,8 +21,8 @@ module NSWTopo
|
|
19
21
|
)
|
20
22
|
EOF
|
21
23
|
yielder << -> { OS.gimp "-c", "-d", "-f", "-i", "-b", gimp_script, "-b", "(gimp-quit TRUE)" }
|
22
|
-
yielder << -> { OS.mogrify
|
23
|
-
raise "pngquant, GIMP or ImageMagick required for dithering"
|
24
|
+
yielder << -> { OS.magick *%w[mogrify -type PaletteBilevelAlpha -dither Riemersma -colors 256], *png_paths }
|
25
|
+
raise Missing, "pngquant, GIMP or ImageMagick required for dithering"
|
24
26
|
end.each do |dither|
|
25
27
|
dither.call
|
26
28
|
break
|
data/lib/nswtopo/font.rb
CHANGED
@@ -1,29 +1,54 @@
|
|
1
|
-
require_relative 'font/generic'
|
2
|
-
require_relative 'font/chrome'
|
3
|
-
|
4
1
|
module NSWTopo
|
5
2
|
module Font
|
6
|
-
|
7
|
-
|
3
|
+
module Chrome
|
4
|
+
include Log
|
5
|
+
ATTRIBUTES = %w[font-family font-variant font-style font-weight font-size letter-spacing word-spacing]
|
6
|
+
SVG = <<~XML
|
7
|
+
<?xml version='1.0' encoding='UTF-8'?>
|
8
|
+
<svg xmlns='http://www.w3.org/2000/svg' width='1mm' height='1mm' viewBox='0 0 1 1' text-rendering='geometricPrecision'>
|
9
|
+
<rect width='1' height='1' stroke='none' />
|
10
|
+
<text>placeholder</text>
|
11
|
+
</svg>
|
12
|
+
XML
|
8
13
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
when !chrome_path
|
15
|
-
log_warn "chrome browser not configured - using generic font measurements"
|
16
|
-
self.extend Generic
|
17
|
-
else
|
18
|
-
begin
|
19
|
-
stdout, stderr, status = Open3.capture3 chrome_path, "--version"
|
20
|
-
raise unless status.success?
|
21
|
-
self.extend Chrome
|
22
|
-
rescue Errno::ENOENT, RuntimeError
|
23
|
-
log_warn "couldn't run chrome - using generic font measurements"
|
24
|
-
self.extend Generic
|
14
|
+
def start_chrome
|
15
|
+
@families = Set[]
|
16
|
+
NSWTopo::Chrome.new("data:image/svg+xml;base64,#{Base64.encode64 SVG}").tap do |browser|
|
17
|
+
@scale = browser.query_selector("rect").width
|
18
|
+
@text = browser.query_selector "text"
|
25
19
|
end
|
26
20
|
end
|
21
|
+
|
22
|
+
def self.extended(instance)
|
23
|
+
instance.start_chrome
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate(attributes)
|
27
|
+
return unless family = attributes["font-family"]
|
28
|
+
return unless @families.add? family
|
29
|
+
@text.value = "abcdefghijklmnopqrstuvwxyz"
|
30
|
+
@text[:style] = "font-family:#{family}"
|
31
|
+
styled_width = @text.width
|
32
|
+
@text[:style] = nil
|
33
|
+
unstyled_width = @text.width
|
34
|
+
log_neutral "font '#{family}' doesn't appear to be available" if styled_width == unstyled_width
|
35
|
+
end
|
36
|
+
|
37
|
+
def glyph_length(string, attributes)
|
38
|
+
validate attributes
|
39
|
+
style = attributes.slice(*ATTRIBUTES).map do |pair|
|
40
|
+
pair.join ?:
|
41
|
+
end.join(?;)
|
42
|
+
@text[:style] = style
|
43
|
+
@text.value = string
|
44
|
+
@text.width / @scale
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
extend self
|
49
|
+
|
50
|
+
def glyph_length(*args)
|
51
|
+
self.extend Chrome
|
27
52
|
glyph_length *args
|
28
53
|
end
|
29
54
|
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module NSWTopo
|
2
|
+
module Formats
|
3
|
+
def render_gemf(gemf_path, name:, **options, &block)
|
4
|
+
Dir.mktmppath do |temp_dir|
|
5
|
+
ranges = tiled_web_map(temp_dir, **options, extension: "gemf", &block).sort_by do |tile|
|
6
|
+
[tile.col, tile.row]
|
7
|
+
end.group_by(&:zoom)
|
8
|
+
|
9
|
+
header, source = "", "nswtopo"
|
10
|
+
# 3.1 overall header:
|
11
|
+
header << [4, 256].pack("L>L>")
|
12
|
+
# 3.2 sources:
|
13
|
+
header << [1, 0, source.bytesize, source].pack("L>L>L>a#{source.bytesize}")
|
14
|
+
# 3.3 number of ranges:
|
15
|
+
header << [ranges.length].pack("L>")
|
16
|
+
|
17
|
+
offset = header.bytesize + ranges.size * 32
|
18
|
+
paths = ranges.each do |zoom, tiles|
|
19
|
+
cols = tiles.map(&:col)
|
20
|
+
rows = tiles.map(&:row)
|
21
|
+
# 3.3 range data:
|
22
|
+
header << [zoom, *cols.minmax, *rows.minmax, 0, offset].pack("L>L>L>L>L>L>Q>")
|
23
|
+
offset += tiles.size * 12
|
24
|
+
end.each do |zoom, tiles|
|
25
|
+
# 3.4 range details:
|
26
|
+
tiles.each do |tile|
|
27
|
+
header << [offset, tile.path.size].pack("Q>L>")
|
28
|
+
offset += tile.path.size
|
29
|
+
end
|
30
|
+
end.values.flatten.map(&:path)
|
31
|
+
|
32
|
+
gemf_path.open("wb") do |file|
|
33
|
+
file.write header
|
34
|
+
# 4 data area:
|
35
|
+
paths.each do |path|
|
36
|
+
file.write path.binread
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/nswtopo/formats/kmz.rb
CHANGED
@@ -3,7 +3,7 @@ module NSWTopo
|
|
3
3
|
module Kmz
|
4
4
|
TILE_SIZE = 512
|
5
5
|
EARTH_RADIUS = 6378137.0
|
6
|
-
TILT = 40 * Math::PI / 180.0
|
6
|
+
TILT = 0 # 40 * Math::PI / 180.0
|
7
7
|
FOV = 25 * Math::PI / 180.0
|
8
8
|
extend self
|
9
9
|
|
@@ -49,40 +49,39 @@ module NSWTopo
|
|
49
49
|
metre_resolution = 0.0254 * @scale / ppi
|
50
50
|
degree_resolution = 180.0 * metre_resolution / Math::PI / Kmz::EARTH_RADIUS
|
51
51
|
|
52
|
-
wgs84_bounds = bounds
|
53
|
-
wgs84_dimensions = wgs84_bounds.transpose.
|
52
|
+
wgs84_bounds = @cutline.reproject_to_wgs84.bounds
|
53
|
+
wgs84_dimensions = wgs84_bounds.transpose.diff / degree_resolution
|
54
|
+
|
54
55
|
max_zoom = Math::log2(wgs84_dimensions.max).ceil - Math::log2(Kmz::TILE_SIZE).to_i
|
55
|
-
topleft = [wgs84_bounds[0][0], wgs84_bounds[1][1]]
|
56
56
|
png_path = yield(ppi: ppi)
|
57
57
|
|
58
58
|
Dir.mktmppath do |temp_dir|
|
59
59
|
pyramid = (0..max_zoom).map do |zoom|
|
60
60
|
resolution = degree_resolution * 2**(max_zoom - zoom)
|
61
61
|
degrees_per_tile = resolution * Kmz::TILE_SIZE
|
62
|
-
counts = (wgs84_bounds.transpose.difference / degrees_per_tile).map(&:ceil)
|
63
|
-
dimensions = counts.times Kmz::TILE_SIZE
|
64
62
|
|
65
|
-
tfw_path = temp_dir / "#{name}.kmz.zoom.#{zoom}.tfw"
|
66
63
|
tif_path = temp_dir / "#{name}.kmz.zoom.#{zoom}.tif"
|
67
|
-
|
68
|
-
|
69
|
-
|
64
|
+
OS.gdalwarp "-t_srs", "EPSG:4326", "-tr", resolution, resolution, "-r", "bilinear", "-dstalpha", png_path, tif_path
|
65
|
+
|
66
|
+
corners = JSON.parse(OS.gdalinfo "-json", tif_path)["cornerCoordinates"]
|
67
|
+
top_left = corners["upperLeft"]
|
68
|
+
counts = corners.values.transpose.map(&:minmax).map do |min, max|
|
69
|
+
(max - min) / degrees_per_tile
|
70
|
+
end.map(&:ceil)
|
70
71
|
|
71
|
-
indices_bounds = [
|
72
|
+
indices_bounds = [top_left, counts, %i[+ -]].transpose.map do |coord, count, increment|
|
72
73
|
boundaries = (0..count).map { |index| coord.send increment, index * degrees_per_tile }
|
73
74
|
[boundaries[0..-2], boundaries[1..-1]].transpose.map(&:sort)
|
74
75
|
end.map do |tile_bounds|
|
75
|
-
tile_bounds.each.with_index.
|
76
|
-
end.inject(:product).map(&:transpose).map
|
77
|
-
{ indices => tile_bounds }
|
78
|
-
end.inject({}, &:merge)
|
76
|
+
tile_bounds.each.with_index.entries
|
77
|
+
end.inject(:product).map(&:transpose).map(&:reverse).to_h
|
79
78
|
|
80
79
|
log_update "kmz: resizing image pyramid: %i%%" % (100 * (2**(zoom + 1) - 1) / (2**(max_zoom + 1) - 1))
|
81
80
|
{ zoom => [indices_bounds, tif_path] }
|
82
81
|
end.inject({}, &:merge)
|
83
82
|
|
84
83
|
kmz_dir = temp_dir.join("#{name}.kmz").tap(&:mkpath)
|
85
|
-
pyramid.
|
84
|
+
pyramid.flat_map do |zoom, (indices_bounds, tif_path)|
|
86
85
|
zoom_dir = kmz_dir.join(zoom.to_s).tap(&:mkpath)
|
87
86
|
indices_bounds.map do |indices, tile_bounds|
|
88
87
|
index_dir = zoom_dir.join(indices.first.to_s).tap(&:mkpath)
|
@@ -114,13 +113,15 @@ module NSWTopo
|
|
114
113
|
end
|
115
114
|
tile_kml_path.write xml
|
116
115
|
|
117
|
-
|
118
|
-
[tif_path, "-quiet", "+repage", "-crop", crop, "+repage", "+dither", "-type", "PaletteBilevelMatte", "PNG8:#{tile_png_path}"]
|
116
|
+
["-srcwin", indices[0] * Kmz::TILE_SIZE, indices[1] * Kmz::TILE_SIZE, Kmz::TILE_SIZE, Kmz::TILE_SIZE, tif_path, tile_png_path]
|
119
117
|
end
|
120
|
-
end.
|
118
|
+
end.tap do |tiles|
|
121
119
|
log_update "kmz: creating %i tiles" % tiles.length
|
122
120
|
end.each.concurrently do |args|
|
123
|
-
OS.
|
121
|
+
OS.gdal_translate "--config", "GDAL_PAM_ENABLED", "NO", *args
|
122
|
+
end.map(&:last).each.concurrent_groups do |tile_png_paths|
|
123
|
+
dither *tile_png_paths
|
124
|
+
rescue Dither::Missing
|
124
125
|
end
|
125
126
|
|
126
127
|
xml = REXML::Document.new
|
@@ -128,10 +129,11 @@ module NSWTopo
|
|
128
129
|
xml.add_element("kml", "xmlns" => "http://earth.google.com/kml/2.1").tap do |kml|
|
129
130
|
kml.add_element("Document").tap do |document|
|
130
131
|
document.add_element("LookAt").tap do |look_at|
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
names_values
|
132
|
+
extents = @dimensions.times(@scale / 1000.0)
|
133
|
+
range_x = extents.first / 2.0 / Math::tan(Kmz::FOV) / Math::cos(Kmz::TILT)
|
134
|
+
range_y = extents.last / Math::cos(Kmz::FOV - Kmz::TILT) / 2 / (Math::tan(Kmz::FOV - Kmz::TILT) + Math::sin(Kmz::TILT))
|
135
|
+
names_values = [%w[longitude latitude], @centre].transpose
|
136
|
+
names_values << ["tilt", Kmz::TILT * 180.0 / Math::PI] << ["range", 1.2 * [range_x, range_y].max] << ["heading", rotation]
|
135
137
|
names_values.each { |name, value| look_at.add_element(name).text = value }
|
136
138
|
end
|
137
139
|
document.add_element("Name").text = name
|