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