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
@@ -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
@@ -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 "--quiet", "--force", "--ext", ".png", "--speed", 1, "--nofs", *png_paths }
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 "-type", "PaletteBilevelAlpha", "-dither", "Riemersma", *png_paths }
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
- include Log
7
- extend self
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
- def glyph_length(*args)
10
- chrome_path = Config["chrome"]
11
- case
12
- when !defined? PTY
13
- self.extend Generic
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
@@ -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(projection: Projection.wgs84)
53
- wgs84_dimensions = wgs84_bounds.transpose.difference / degree_resolution
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
- WorldFile.write topleft, resolution, 0, tfw_path
68
- OS.convert "-size", dimensions.join(?x), "canvas:none", "-type", "TrueColorMatte", "-depth", 8, tif_path
69
- OS.gdalwarp "-s_srs", @projection, "-t_srs", Projection.wgs84, "-r", "bilinear", "-dstalpha", png_path, tif_path
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 = [topleft, counts, %i[+ -]].transpose.map do |coord, count, increment|
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.to_a
76
- end.inject(:product).map(&:transpose).map do |tile_bounds, indices|
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.map do |zoom, (indices_bounds, tif_path)|
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
- crop = "%ix%i+%i+%s" % [Kmz::TILE_SIZE, Kmz::TILE_SIZE, indices[0] * Kmz::TILE_SIZE, indices[1] * Kmz::TILE_SIZE]
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.flatten(1).tap do |tiles|
118
+ end.tap do |tiles|
121
119
  log_update "kmz: creating %i tiles" % tiles.length
122
120
  end.each.concurrently do |args|
123
- OS.convert *args
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
- range_x = @extents.first / 2.0 / Math::tan(Kmz::FOV) / Math::cos(Kmz::TILT)
132
- range_y = @extents.last / Math::cos(Kmz::FOV - Kmz::TILT) / 2 / (Math::tan(Kmz::FOV - Kmz::TILT) + Math::sin(Kmz::TILT))
133
- names_values = [%w[longitude latitude], wgs84_centre].transpose
134
- names_values << ["tilt", Kmz::TILT * 180.0 / Math::PI] << ["range", 1.2 * [range_x, range_y].max] << ["heading", @rotation]
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