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
@@ -1,14 +1,7 @@
1
1
  module NSWTopo
2
2
  module Formats
3
- module Mbtiles
4
- RESOLUTION, ORIGIN, TILE_SIZE, ZOOM = 2 * 78271.516, -20037508.34, 256, 16
5
- end
6
-
7
- def render_mbtiles(mbtiles_path, name:, zoom: Mbtiles::ZOOM, **options)
8
- raise "invalid zoom outside 10-19 range: #{zoom}" unless (10..19) === zoom
9
-
10
- web_mercator_bounds = bounds(projection: Projection.new("EPSG:3857"))
11
- wgs84_bounds = bounds(projection: Projection.wgs84)
3
+ def render_mbtiles(mbtiles_path, name:, **options, &block)
4
+ wgs84_bounds = @cutline.reproject_to_wgs84.bounds
12
5
  sql = <<~SQL
13
6
  CREATE TABLE metadata (name TEXT, value TEXT);
14
7
  INSERT INTO metadata VALUES ("name", "#{name}");
@@ -21,39 +14,10 @@ module NSWTopo
21
14
  SQL
22
15
 
23
16
  Dir.mktmppath do |temp_dir|
24
- png_path = nil
25
- zoom.downto(0).inject([]) do |levels, zoom|
26
- resolution = Mbtiles::RESOLUTION / 2**zoom
27
- indices, dimensions, topleft = web_mercator_bounds.map do |lower, upper|
28
- ((lower - Mbtiles::ORIGIN) / resolution / Mbtiles::TILE_SIZE).floor ... ((upper - Mbtiles::ORIGIN) / resolution / Mbtiles::TILE_SIZE).ceil
29
- end.map.with_index do |indices, axis|
30
- [indices, (indices.last - indices.first) * Mbtiles::TILE_SIZE, Mbtiles::ORIGIN + (axis.zero? ? indices.first : indices.last) * Mbtiles::TILE_SIZE * resolution]
31
- end.transpose
32
- tile_path = temp_dir.join("#{name}.mbtiles.#{zoom}.%09d.png").to_s
33
- levels << [resolution, indices, dimensions, topleft, tile_path, zoom]
34
- break levels if indices.map(&:size).all? { |size| size < 3 }
35
- levels
36
- end.tap do |(resolution, *, zoom), *|
37
- png_path = yield(resolution: resolution)
38
- end.tap do |levels|
39
- log_update "mbtiles: tiling for zoom levels %s" % levels.map(&:last).minmax.uniq.join(?-)
40
- end.each.concurrently do |resolution, indices, dimensions, topleft, tile_path, zoom|
41
- tif_path, tfw_path = %w[tif tfw].map { |ext| temp_dir / "#{name}.mbtiles.#{zoom}.#{ext}" }
42
- WorldFile.write topleft, resolution, 0, tfw_path
43
- OS.convert "-size", dimensions.join(?x), "canvas:none", "-type", "TrueColorAlpha", "-depth", 8, tif_path
44
- OS.gdalwarp "-s_srs", @projection, "-t_srs", "EPSG:3857", "-r", "lanczos", "-dstalpha", png_path, tif_path
45
- OS.convert tif_path, "-quiet", "+repage", "-crop", "#{Mbtiles::TILE_SIZE}x#{Mbtiles::TILE_SIZE}", tile_path
46
- end.map do |resolution, indices, dimensions, topleft, tile_path, zoom|
47
- indices[1].to_a.reverse.product(indices[0].to_a).map.with_index do |(row, col), index|
48
- [tile_path % index, zoom, col, row]
49
- end
50
- end.flatten(1).each do |tile_path, zoom, col, row|
51
- sql << %Q[INSERT INTO tiles VALUES (#{zoom}, #{col}, #{row}, readfile("#{tile_path}"));\n]
52
- end.tap do |tiles|
53
- log_update "mbtiles: optimising %i tiles" % tiles.length
54
- end.map(&:first).each.concurrent_groups do |png_paths|
55
- dither *png_paths
17
+ tiled_web_map(temp_dir, **options, extension: "mbtiles", &block).each do |tile|
18
+ sql << %Q[INSERT INTO tiles VALUES (#{tile.zoom}, #{tile.col}, #{tile.row}, readfile("#{tile.path}"));\n]
56
19
  end
20
+
57
21
  OS.sqlite3 mbtiles_path do |stdin|
58
22
  stdin.puts sql
59
23
  stdin.puts ".exit"
@@ -1,28 +1,93 @@
1
1
  module NSWTopo
2
2
  module Formats
3
- def render_pdf(pdf_path, ppi: nil, external: nil, **options)
3
+ def render_pdf(pdf_path, ppi: nil, background:, **options)
4
4
  if ppi
5
- OS.gdal_translate "-a_srs", @projection, "-of", "PDF", "-co", "DPI=#{ppi}", "-co", "MARGIN=0", "-co", "CREATOR=nswtopo", "-co", "GEO_ENCODING=ISO32000", yield(ppi: ppi), pdf_path
5
+ OS.gdal_translate "-of", "PDF", "-co", "DPI=#{ppi}", "-co", "MARGIN=0", "-co", "CREATOR=nswtopo", "-co", "GEO_ENCODING=ISO32000", yield(ppi: ppi), pdf_path
6
6
  else
7
7
  Dir.mktmppath do |temp_dir|
8
8
  svg_path = temp_dir / "pdf-map.svg"
9
- render_svg svg_path, external: external
10
- xml = REXML::Document.new svg_path.read
11
- style = "@media print { @page { margin: 0 0 -1mm 0; size: %s %s; } }"
12
- svg = xml.elements["svg"]
13
- svg.add_element("style").text = style % svg.attributes.values_at("width", "height")
14
- svg_path.write xml
9
+ render_svg svg_path, background: background
15
10
 
16
- FileUtils.rm pdf_path if pdf_path.exist?
17
- NSWTopo.with_browser do |browser_name, browser_path|
18
- args = case browser_name
19
- when "chrome"
20
- ["--headless", "--disable-gpu", "--print-to-pdf=#{pdf_path}"]
21
- when "firefox"
22
- raise "can't create vector PDF with firefox; use chrome or specify ppi for a raster PDF"
11
+ REXML::Document.new(svg_path.read).tap do |xml|
12
+ xml.elements["svg"].tap do |svg|
13
+ style = "@media print { @page { margin: 0; size: %s %s; } }"
14
+ svg.add_element("style").text = style % svg.attributes.values_at("width", "height")
15
+ end
16
+
17
+ # replace fill pattern paint with manual pattern mosaic to work around Chrome PDF bug
18
+ xml.elements.each("//svg//use[@id][@fill][@href]") do |use|
19
+ id = use.attributes["id"]
20
+
21
+ # find the pattern id, content id, pattern element and content element
22
+ next unless /^url\(#(?<pattern_id>.*)\)$/ =~ use.attributes["fill"]
23
+ next unless /^#(?<content_id>.*)$/ =~ use.attributes["href"]
24
+ next unless pattern = use.elements["preceding::defs/pattern[@id='#{pattern_id}'][@width][@height]"]
25
+ next unless content = use.elements["preceding::defs/g[@id='#{content_id}']"]
26
+
27
+ # change pattern element to a group
28
+ pattern.attributes.delete "patternUnits"
29
+ pattern.name = "g"
30
+
31
+ # create a clip path to apply to the fill pattern mosaic
32
+ content_clip = REXML::Element.new "clipPath"
33
+ content_clip.add_attribute "id", "#{content_id}.clip"
34
+
35
+ # create a clip path to apply to pattern element
36
+ pattern_clip = REXML::Element.new "clipPath"
37
+ pattern_clip.add_attribute "id", "#{pattern_id}.clip"
38
+ pattern.add_attribute "clip-path", "url(##{pattern_id}.clip)"
39
+
40
+ # move content and clip paths into defs
41
+ pattern.previous_sibling = pattern_clip
42
+ pattern.next_sibling = content
43
+ content.next_sibling = content_clip
44
+
45
+ # replace fill paint with a container for the fill pattern mosaic
46
+ fill = REXML::Element.new "g"
47
+ fill.add_attribute "clip-path", "url(##{content_id}.clip)"
48
+ fill.add_attribute "id", "#{id}.fill"
49
+ use.previous_sibling = fill
50
+ use.add_attribute "fill", "none"
51
+
52
+ xml.elements.each("//use[@href='##{id}']") do |use|
53
+ use_fill = REXML::Element.new "use"
54
+ use_fill.add_attribute "href", "##{id}.fill"
55
+ use.previous_sibling = use_fill
56
+ end
57
+
58
+ # get pattern size
59
+ pattern_size = %w[width height].map do |name|
60
+ pattern.attributes[name].tap { pattern.attributes.delete name }
61
+ end.map(&:to_f)
62
+
63
+ # create pattern clip
64
+ pattern_size.each.with_object(0).inject(&:product).values_at(3,2,0,1).tap do |corners|
65
+ pattern_clip.add_element "path", "d" => %w[M L L L].zip(corners).push("Z").join(?\s)
66
+ end
67
+
68
+ # add paths to content clip, get content coverage area, and create fill pattern mosaic
69
+ content.elements.collect("path[@d]", &:itself).each.with_index do |path, index|
70
+ path.add_attribute "id", "#{content_id}.#{index}"
71
+ content_clip.add_element "use", "href" => "##{content_id}.#{index}"
72
+ end.flat_map do |path|
73
+ path.attributes["d"].scan /(\d+(?:\.\d+)?) (\d+(?:\.\d+)?)/
74
+ end.transpose.map do |coords|
75
+ coords.map(&:to_f).minmax
76
+ end.zip(pattern_size).map do |(min, max), size|
77
+ (min...max).step(size).entries
78
+ end.inject(&:product).each do |x, y|
79
+ fill.add_element "use", "href" => "##{pattern_id}", "x" => x, "y" => y
80
+ end
23
81
  end
24
- stdout, stderr, status = Open3.capture3 browser_path.to_s, *args, "file://#{svg_path}"
25
- raise "couldn't create PDF using %s" % browser_name unless status.success? && pdf_path.file?
82
+
83
+ svg_path.write xml
84
+ end
85
+
86
+ FileUtils.rm pdf_path if pdf_path.exist?
87
+ log_update "chrome: rendering PDF"
88
+
89
+ Chrome.with_browser("file://#{svg_path}") do |browser|
90
+ browser.print_to_pdf pdf_path
26
91
  end
27
92
  end
28
93
  end
@@ -1,68 +1,137 @@
1
1
  module NSWTopo
2
+ class SVGFormatter < REXML::Formatters::Pretty
3
+ def initialize(*args)
4
+ super
5
+ self.compact, @default = true, REXML::Formatters::Default.new
6
+ end
7
+
8
+ def write_element(node, output)
9
+ case node.name
10
+ when "text"
11
+ output << ' ' * @level
12
+ @default.write_element node, output
13
+ else
14
+ super
15
+ end
16
+ end
17
+ end
18
+
2
19
  module Formats
3
- def render_svg(svg_path, external: nil, **options)
4
- case
5
- when external
6
- raise "not a file: %s" % external unless external.file?
7
- begin
8
- svg = REXML::Document.new(external.read).elements["svg"]
9
- raise "not an SVG file: %s" % external unless svg
10
- desc = svg.elements["metadata/rdf:RDF/rdf:Description[@dc:creator='nswtopo']"]
11
- raise "not an nswtopo SVG file: %s" % external unless desc
12
- rescue REXML::ParseException
13
- raise "not an SVG file: %s" % external
14
- end
15
- FileUtils.cp external, svg_path
20
+ def neatline_path_data
21
+ @neatline.coordinates.map do |ring|
22
+ ring.map do |point|
23
+ point.join(" ")
24
+ end.join(" L ").prepend("M ").concat(" Z")
25
+ end.join(" ")
26
+ end
16
27
 
17
- when @archive.uptodate?("map.svg", "map.yml")
18
- svg_path.write @archive.read("map.svg")
28
+ def render_svg(svg_path, background:, **options)
29
+ if uptodate?("map.svg", "map.yml")
30
+ log_update "nswtopo: reading existing map SVG"
31
+ xml = REXML::Document.new read("map.svg")
32
+ xml.elements["svg/metadata/rdf:RDF/rdf:Description"].add_attributes("xmp:ModifyDate" => Time.now.iso8601)
19
33
 
20
34
  else
21
- width, height = extents.times(1000.0 / scale)
35
+ width, height = @dimensions
22
36
  xml = REXML::Document.new
23
37
  xml << REXML::XMLDecl.new(1.0, "utf-8")
24
38
  svg = xml.add_element "svg",
25
- "version" => 1.1,
26
- "baseProfile" => "full",
27
39
  "width" => "#{width}mm",
28
40
  "height" => "#{height}mm",
29
41
  "viewBox" => "0 0 #{width} #{height}",
30
- "xmlns" => "http://www.w3.org/2000/svg",
31
- "xmlns:xlink" => "http://www.w3.org/1999/xlink",
32
- "xmlns:sodipodi" => "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
33
- "xmlns:inkscape" => "http://www.inkscape.org/namespaces/inkscape"
42
+ "text-rendering" => "geometricPrecision",
43
+ "xmlns" => "http://www.w3.org/2000/svg",
44
+ "xmlns:nswtopo" => "http://nswtopo.com"
34
45
 
35
- meta = svg.add_element "metadata"
36
- rdf = meta.add_element "rdf:RDF",
46
+ metadata = svg.add_element("metadata")
47
+ metadata.add_element("nswtopo:map",
48
+ "projection" => @neatline.projection.wkt2,
49
+ "neatline" => @neatline.coordinates.to_json,
50
+ "centre" => @centre.to_json,
51
+ "scale" => @scale,
52
+ "rotation" => @rotation
53
+ )
54
+ metadata.add_element("rdf:RDF",
37
55
  "xmlns:rdf" => "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
56
+ "xmlns:xmp" => "http://ns.adobe.com/xap/1.0/",
38
57
  "xmlns:dc" => "http://purl.org/dc/elements/1.1/"
39
- rdf.add_element "rdf:Description",
40
- "dc:date" => Date.today.iso8601,
41
- "dc:format" => "image/svg+xml",
42
- "dc:creator" => "nswtopo"
58
+ ).add_element("rdf:Description",
59
+ "xmp:CreatorTool" => VERSION.creator_string,
60
+ "dc:format" => "image/svg+xml"
61
+ )
43
62
 
44
- defs = svg.add_element "defs"
45
- svg.add_element "sodipodi:namedview", "borderlayer" => true
46
- svg.add_element "rect", "x" => 0, "y" => 0, "width" => width, "height" => height, "fill" => "white"
63
+ # add defs for map filters and masks
64
+ defs = svg.add_element("defs", "id" => "map.defs")
65
+ defs.add_element("rect", "id" => "map.rect", "width" => width, "height" => height)
66
+ defs.add_element("path", "id" => "map.neatline", "d" => neatline_path_data)
67
+ defs.add_element("clipPath", "id" => "map.clip").add_element("use", "href" => "#map.neatline")
47
68
 
48
- labels = Layer.new "labels", self, Config.fetch("labels", {}).merge("type" => "Labels")
49
- layers.reject(&:empty?).each do |layer|
50
- next if Config["labelling"] == false
51
- labels.add layer if Vector === layer
52
- end.push(labels).each do |layer|
53
- log_update "compositing: #{layer.name}"
54
- group = svg.add_element "g", "id" => layer.name, "inkscape:groupmode" => "layer"
55
- layer.render group, defs, &labels.method(:add_fence)
69
+ # add a filter converting alpha channel to cutout mask
70
+ defs.add_element("filter", "id" => "map.filter.cutout").tap do |filter|
71
+ filter.add_element("feComponentTransfer", "in" => "SourceAlpha")
56
72
  end
57
73
 
58
- until xml.elements.each("svg//g[not(*)]", &:remove).empty? do
74
+ Enumerator.new do |yielder|
75
+ labels = Layer.new "labels", self, Config.fetch("labels", {}).merge("type" => "Labels")
76
+ layers.reject do |layer|
77
+ log_update "reading: #{layer.name}"
78
+ layer.empty?
79
+ end.each do |layer|
80
+ next if Config["labelling"] == false
81
+ labels.add layer if Vector === layer
82
+ end.push(labels).each.with_object [[], []] do |layer, (cutouts, knockouts)|
83
+ log_update "compositing: #{layer.name}"
84
+ new_knockouts, knockout = [], "map.mask.knockout.#{knockouts.length+1}"
85
+ layer.render(cutouts: cutouts, knockout: knockout) do |object|
86
+ case object
87
+ when Labels::Barrier then labels << object
88
+ when Vector::Cutout then cutouts << object
89
+ when Vector::Knockout then new_knockouts << object
90
+ when REXML::Element
91
+ object.attributes["mask"] ||= "url(#map.mask.knockout.#{knockouts.length})" unless "defs" == object.name
92
+ yielder << object
93
+ end
94
+ end
95
+ knockouts << new_knockouts if new_knockouts.any?
96
+ end.last.push([]).each.with_index do |knockouts, index|
97
+ mask = defs.add_element("mask", "id" => "map.mask.knockout.#{index}")
98
+ content = mask.add_element("g", "id" => "map.mask.knockout.#{index}.content")
99
+ content.add_element("use", "href" => "#map.mask.knockout.#{index+1}.content") if knockouts.any?
100
+ content.add_element("use", "href" => "#map.rect", "fill" => "white", "stroke" => "none") if knockouts.none?
101
+ knockouts.group_by(&:buffer).map do |buffer, knockouts|
102
+ group = content.add_element("g", "filter" => "url(#map.filter.knockout.#{buffer})")
103
+ knockouts.each do |knockout|
104
+ group.add_element knockout.use
105
+ end
106
+ end
107
+ end.flatten.group_by(&:buffer).keys.each do |buffer|
108
+ filter = defs.add_element("filter", "id" => "map.filter.knockout.#{buffer}")
109
+ filter.add_element("feColorMatrix", "values" => "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 5 0")
110
+ filter.add_element("feMorphology", "operator" => "dilate", "radius" => buffer) unless buffer.zero?
111
+ filter.add_element("feComponentTransfer").add_element("feFuncA", "type" => "discrete", "tableValues" => "0 1")
112
+ end
113
+ end.reject do |element|
114
+ svg.add_element(element) if "defs" == element.name
115
+ end.tap do
116
+ svg.add_element("use", "id" => "map.background", "href" => "#map.neatline", "fill" => "white")
117
+ end.chunk do |element|
118
+ element.attributes["mask"]
119
+ end.each.with_object(svg.add_element("g", "clip-path" => "url(#map.clip)")) do |(mask, elements), clip_group|
120
+ elements.each.with_object(clip_group.add_element("g", "mask" => mask)) do |element, mask_group|
121
+ mask_group.add_element element
122
+ element.delete_attribute "mask"
123
+ end
59
124
  end
60
125
 
61
- string, formatter = String.new, REXML::Formatters::Pretty.new
62
- formatter.compact = true
63
- formatter.write xml, string
64
- write "map.svg", string
65
- svg_path.write string
126
+ xml.elements.each("svg//defs[not(*)]", &:remove)
127
+ xml.elements["svg/metadata/rdf:RDF/rdf:Description"].add_attributes %w[xmp:ModifyDate xmp:CreateDate].each.with_object(Time.now.iso8601).to_h
128
+ write "map.svg", xml.to_s
129
+ end
130
+
131
+ xml.elements["svg/use[@id='map.background']"].add_attributes("fill" => background) if background
132
+
133
+ svg_path.open("w") do |file|
134
+ SVGFormatter.new.write xml, file
66
135
  end
67
136
  end
68
137
  end
@@ -1,9 +1,9 @@
1
1
  module NSWTopo
2
2
  module Formats
3
- def render_svgz(svgz_path, external: nil, **options)
3
+ def render_svgz(svgz_path, background:, **options)
4
4
  Dir.mktmppath do |temp_dir|
5
5
  svg_path = temp_dir / "svgz-map.svg"
6
- render_svg svg_path, external: external
6
+ render_svg svg_path, background: background
7
7
  Zlib::GzipWriter.open svgz_path do |gz|
8
8
  gz.write svg_path.binread
9
9
  end
@@ -2,37 +2,47 @@ module NSWTopo
2
2
  module Formats
3
3
  def render_zip(zip_path, name:, ppi: PPI, **options)
4
4
  Dir.mktmppath do |temp_dir|
5
- zip_dir = temp_dir.join("#{name}.avenza").tap(&:mkpath)
5
+ zip_dir = temp_dir.join("zip").tap(&:mkpath)
6
6
  tiles_dir = zip_dir.join("tiles").tap(&:mkpath)
7
7
  png_path = yield(ppi: ppi)
8
- top_left = bounding_box.coordinates[0][3]
9
8
 
10
9
  2.downto(0).map.with_index do |level, index|
11
- [level, index, ppi.to_f / 2**index]
12
- end.each.concurrently do |level, index, ppi|
13
- dimensions, ppi, resolution = raster_dimensions_at ppi: ppi
14
- img_path = index.zero? ? png_path : temp_dir / "#{name}.avenza.#{level}.png"
15
- tile_path = temp_dir.join("#{name}.avenza.tile.#{level}.%09d.png").to_s
16
-
17
- OS.convert png_path, "-filter", "Lanczos", "-resize", "%ix%i!" % dimensions, img_path unless img_path.exist?
18
- OS.convert img_path, "+repage", "-crop", "256x256", tile_path
19
-
20
- dimensions.reverse.map do |dimension|
21
- 0.upto((dimension - 1) / 256).to_a
22
- end.inject(&:product).each.with_index do |(y, x), n|
23
- FileUtils.cp tile_path % n, tiles_dir / "#{level}x#{y}x#{x}.png"
10
+ geo_transform = geotransform(ppi: ppi / 2**index)
11
+ outsize = (@dimensions / geo_transform[1]).map(&:ceil)
12
+ case index
13
+ when 0
14
+ thumb_size = outsize.inject(&:<) ? [0, 64] : [64, 0]
15
+ OS.gdal_translate *%w[--config GDAL_PAM_ENABLED NO -r bilinear -outsize], *thumb_size, png_path, zip_dir / "thumb.png"
16
+ when 1
17
+ zip_dir.join("#{name}.ref").open("w") do |file|
18
+ file.puts @projection.wkt2
19
+ file.puts geo_transform.join(?,)
20
+ file.puts outsize.join(?,)
21
+ end
24
22
  end
25
- zip_dir.join("#{name}.ref").open("w") do |file|
26
- file.puts @projection.wkt_simple
27
- file.puts WorldFile.geotransform(top_left, resolution, -@rotation).flatten.join(?,)
28
- file << dimensions.join(?,)
29
- end if index == 1
30
- end
31
- Pathname.glob(tiles_dir / "*.png").each.concurrent_groups do |tile_paths|
23
+ img_path = index.zero? ? png_path : temp_dir / "map.#{level}.png"
24
+ next level, outsize, img_path
25
+ end.each.concurrently do |level, outsize, img_path|
26
+ OS.gdal_translate *%w[-r bicubic -outsize], *outsize, png_path, img_path unless img_path.exist?
27
+ end.flat_map do |level, outsize, img_path|
28
+ outsize.map do |px|
29
+ (0...px).step(256).with_index.entries
30
+ end.inject(&:product).map do |(col, j), (row, i)|
31
+ tile_path = tiles_dir / "#{level}x#{i}x#{j}.png"
32
+ size = [-col, -row].zip(outsize).map(&:sum).zip([256, 256]).map(&:min)
33
+ %w[--config GDAL_PAM_ENABLED NO -srcwin] + [col, row, *size, img_path, tile_path]
34
+ end
35
+ end.tap do |tiles|
36
+ log_update "zip: creating %i tiles" % tiles.length
37
+ end.each.concurrently do |args|
38
+ OS.gdal_translate *args
39
+ end.map(&:last).tap do |tile_paths|
40
+ log_update "zip: optimising %i tiles" % tile_paths.length
41
+ end.each.concurrent_groups do |tile_paths|
32
42
  dither *tile_paths
43
+ rescue Dither::Missing
33
44
  end
34
45
 
35
- OS.convert png_path, "-thumbnail", "64x64", "-gravity", "center", "-background", "white", "-extent", "64x64", "-alpha", "Remove", "-type", "TrueColor", zip_dir / "thumb.png"
36
46
  zip zip_dir, zip_path
37
47
  end
38
48
  end
@@ -1,6 +1,7 @@
1
1
  require_relative 'formats/svg'
2
2
  require_relative 'formats/kmz'
3
3
  require_relative 'formats/mbtiles'
4
+ require_relative 'formats/gemf'
4
5
  require_relative 'formats/zip'
5
6
  require_relative 'formats/pdf'
6
7
  require_relative 'formats/svgz'
@@ -9,6 +10,8 @@ module NSWTopo
9
10
  module Formats
10
11
  include Log
11
12
  PPI = 300
13
+ TILE = 1500
14
+ ARGS = %w[--force-gpu-mem-available-mb=4096]
12
15
 
13
16
  def self.extensions
14
17
  instance_methods.grep(/^render_([a-z]+)/) { $1 }
@@ -19,57 +22,99 @@ module NSWTopo
19
22
  end
20
23
 
21
24
  def render_png(png_path, ppi: PPI, dither: false, **options)
25
+ ppm = (ppi / 0.0254).round
26
+ OS.exiftool yield(ppi: ppi, dither: dither), *%W[
27
+ -PNG:PixelsPerUnitX=#{ppm}
28
+ -PNG:PixelsPerUnitY=#{ppm}
29
+ -o #{png_path}
30
+ ]
31
+ rescue OS::Missing
22
32
  FileUtils.cp yield(ppi: ppi, dither: dither), png_path
23
33
  end
24
34
 
25
35
  def render_tif(tif_path, ppi: PPI, dither: false, **options)
26
- OS.gdal_translate "-of", "GTiff", "-co", "COMPRESS=DEFLATE", "-co", "ZLEVEL=9", "-a_srs", @projection, yield(ppi: ppi, dither: dither), tif_path
36
+ OS.gdal_translate yield(ppi: ppi, dither: dither), *%W[
37
+ -of GTiff
38
+ -co COMPRESS=DEFLATE
39
+ -co ZLEVEL=9
40
+ -mo TIFFTAG_XRESOLUTION=#{ppi}
41
+ -mo TIFFTAG_YRESOLUTION=#{ppi}
42
+ -mo TIFFTAG_RESOLUTIONUNIT=2
43
+ ], tif_path
27
44
  end
28
45
 
29
46
  def render_jpg(jpg_path, ppi: PPI, **options)
30
- OS.gdal_translate "-of", "JPEG", "-co", "QUALITY=90", "-mo", "EXIF_XResolution=#{ppi}", "-mo", "EXIF_YResolution=#{ppi}", "-mo", "EXIF_ResolutionUnit=2", yield(ppi: ppi), jpg_path
47
+ OS.gdal_translate yield(ppi: ppi), *%W[
48
+ -of JPEG
49
+ -co QUALITY=90
50
+ -mo EXIF_XResolution=#{ppi}
51
+ -mo EXIF_YResolution=#{ppi}
52
+ -mo EXIF_ResolutionUnit=2
53
+ ], jpg_path
31
54
  end
32
55
 
33
- def rasterise(png_path, external:, **options)
56
+ def rasterise(png_path, background:, ppi: nil, resolution: nil)
34
57
  Dir.mktmppath do |temp_dir|
35
- dimensions, ppi, resolution = raster_dimensions_at **options
36
58
  svg_path = temp_dir / "map.svg"
37
- src_path = temp_dir / "browser.svg"
38
- render_svg svg_path, external: external
59
+ vrt_path = temp_dir / "map.vrt"
60
+ render_svg svg_path, background: background
39
61
 
40
- NSWTopo.with_browser do |browser_name, browser_path|
41
- megapixels = dimensions.inject(&:*) / 1024.0 / 1024.0
42
- log_update "%s: creating %i×%i (%.1fMpx) map raster at %i ppi" % [browser_name, *dimensions, megapixels, options[:ppi] ] if options[:ppi]
43
- log_update "%s: creating %i×%i (%.1fMpx) map raster at %.1f m/px" % [browser_name, *dimensions, megapixels, options[:resolution]] if options[:resolution]
62
+ case
63
+ when ppi
64
+ ppi_info = "%i ppi" % ppi
65
+ mm_per_px = 25.4 / ppi
66
+ when resolution
67
+ ppi_info = "%.1f m/px" % resolution
68
+ mm_per_px = to_mm(resolution)
69
+ end
70
+
71
+ viewport_size = [TILE * mm_per_px] * 2
72
+ raster_size = (@dimensions / mm_per_px).map(&:ceil)
73
+ megapixels = raster_size.inject(&:*) / 1024.0 / 1024.0
74
+
75
+ raster_info = "%i×%i (%.1fMpx) map raster at %s" % [*raster_size, megapixels, ppi_info]
76
+ chrome_message = "chrome: creating #{raster_info}"
77
+ log_update chrome_message
44
78
 
45
- render = lambda do |width, height|
46
- args = case browser_name
47
- when "firefox"
48
- ["--window-size=#{width},#{height}", "-headless", "-screenshot", png_path.to_s]
49
- when "chrome"
50
- ["--window-size=#{width},#{height}", "--headless", "--screenshot=#{png_path}", "--disable-lcd-text", "--disable-extensions", "--hide-scrollbars", "--disable-gpu"]
79
+ NSWTopo::Chrome.with_browser "file://#{svg_path}", width: TILE, height: TILE, args: ARGS do |browser|
80
+ svg = browser.query_selector "svg"
81
+ svg[:width], svg[:height] = nil, nil
82
+ svg[:viewBox].split.map(&:to_f).last(2).map do |mm|
83
+ (0...(mm / mm_per_px).ceil).step(TILE).map do |px|
84
+ [px, px * mm_per_px]
51
85
  end
52
- FileUtils.rm png_path if png_path.exist?
53
- stdout, stderr, status = Open3.capture3 browser_path.to_s, *args, "file://#{src_path}"
54
- case browser_name
55
- when "firefox" then raise "couldn't rasterise map using firefox (ensure browser is closed)"
56
- when "chrome" then raise "couldn't rasterise map using chrome"
57
- end unless status.success? && png_path.file?
58
- end
86
+ end.inject(&:product).map(&:transpose).tap do |grid|
87
+ chrome_message += " (tile %i of #{grid.size})"
88
+ end.map.with_index do |(raster_offset, viewport_offset), index|
89
+ log_update chrome_message % [index + 1]
90
+
91
+ tile_path = temp_dir.join("tile.%i.%i.png" % raster_offset)
92
+ viewbox = [*viewport_offset, *viewport_size].join(?\s)
59
93
 
60
- src_path.write %Q[<?xml version='1.0' encoding='UTF-8'?><svg version='1.1' baseProfile='full' xmlns='http://www.w3.org/2000/svg'></svg>]
61
- render.call 1000, 1000
62
- json = NSWTopo::OS.gdalinfo "-json", png_path
63
- scaling = JSON.parse(json)["size"][0] / 1000.0
94
+ svg[:viewBox] = viewbox
95
+ browser.screenshot tile_path
64
96
 
65
- svg = %w[width height].inject(svg_path.read) do |svg, attribute|
66
- svg.sub(/#{attribute}='(.*?)mm'/) { %Q[#{attribute}='#{$1.to_f * ppi / 96.0 / scaling}mm'] }
97
+ REXML::Document.new(OS.gdal_translate "-of", "VRT", tile_path, "/vsistdout/").tap do |vrt|
98
+ vrt.elements.each("VRTDataset/VRTRasterBand/SimpleSource/DstRect") do |dst_rect|
99
+ dst_rect.add_attributes "xOff" => raster_offset[0], "yOff" => raster_offset[1]
100
+ end
101
+ end
102
+ end.inject do |vrt, tile_vrt|
103
+ vrt.elements["VRTDataset/VRTRasterBand[@band='1']"].add_element tile_vrt.elements["VRTDataset/VRTRasterBand[@band='1']/SimpleSource"]
104
+ vrt.elements["VRTDataset/VRTRasterBand[@band='2']"].add_element tile_vrt.elements["VRTDataset/VRTRasterBand[@band='2']/SimpleSource"]
105
+ vrt.elements["VRTDataset/VRTRasterBand[@band='3']"].add_element tile_vrt.elements["VRTDataset/VRTRasterBand[@band='3']/SimpleSource"]
106
+ vrt.elements["VRTDataset/VRTRasterBand[@band='4']"].add_element tile_vrt.elements["VRTDataset/VRTRasterBand[@band='4']/SimpleSource"]
107
+ vrt
108
+ end.tap do |vrt|
109
+ vrt.elements.each("VRTDataset/VRTRasterBand/@blockYSize", &:remove)
110
+ vrt.elements.each("VRTDataset/Metadata", &:remove)
111
+ vrt.elements["VRTDataset"].add_attributes "rasterXSize" => raster_size[0], "rasterYSize" => raster_size[1]
112
+ File.write vrt_path, vrt
67
113
  end
68
- src_path.write svg
69
- render.call *(dimensions / scaling).map(&:ceil)
70
114
  end
71
115
 
72
- OS.mogrify "+repage", "-crop", "#{dimensions.join ?x}+0+0", "-background", "white", "-flatten", "-alpha", "Off", "-units", "PixelsPerInch", "-density", ppi, "-define", "PNG:exclude-chunk=bkgd,itxt,ztxt,text,chrm", png_path
116
+ log_update "nswtopo: finalising #{raster_info}"
117
+ OS.gdal_translate vrt_path, png_path
73
118
  end
74
119
  end
75
120
  end
@@ -39,40 +39,9 @@ module Overlap
39
39
  end
40
40
  end
41
41
 
42
- def overlap?(buffer = 0)
42
+ def overlap?(buffer)
43
43
  !separated_by?(buffer)
44
44
  end
45
-
46
- def overlaps(buffer = 0)
47
- return [] if empty?
48
- axis = flatten(1).transpose.map { |values| values.max - values.min }.map.with_index.max.last
49
- events, tops, bots, results = AVLTree.new, [], [], []
50
- margin = [buffer, 0]
51
- each.with_index do |hull, index|
52
- min, max = hull.map { |point| point.rotate axis }.minmax
53
- events << [min.minus(margin), index, :start]
54
- events << [max.plus( margin), index, :stop ]
55
- end
56
- events.each do |point, index, event|
57
- top, bot = at(index).transpose[1-axis].minmax
58
- case event
59
- when :start
60
- not_above = bots.select { |bot, other| bot >= top - buffer }.map(&:last)
61
- not_below = tops.select { |top, other| top <= bot + buffer }.map(&:last)
62
- (not_below & not_above).reject do |other|
63
- values_at(index, other).separated_by? buffer
64
- end.each do |other|
65
- results << [index, other]
66
- end
67
- tops << [top, index]
68
- bots << [bot, index]
69
- when :stop
70
- tops.delete [top, index]
71
- bots.delete [bot, index]
72
- end
73
- end
74
- results
75
- end
76
45
  end
77
46
 
78
47
  Array.send :include, Overlap