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.
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