nswtopo 2.0.0.pre.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/COPYING +674 -0
  3. data/bin/nswtopo +430 -0
  4. data/docs/README.md +78 -0
  5. data/docs/add.md +49 -0
  6. data/docs/config.md +24 -0
  7. data/docs/contours.md +37 -0
  8. data/docs/controls.md +9 -0
  9. data/docs/declination.md +15 -0
  10. data/docs/delete.md +15 -0
  11. data/docs/grid.md +5 -0
  12. data/docs/info.md +5 -0
  13. data/docs/init.md +38 -0
  14. data/docs/layers.md +11 -0
  15. data/docs/overlay.md +37 -0
  16. data/docs/relief.md +22 -0
  17. data/docs/render.md +43 -0
  18. data/docs/spot-heights.md +23 -0
  19. data/lib/nswtopo/archive.rb +93 -0
  20. data/lib/nswtopo/avl_tree.rb +128 -0
  21. data/lib/nswtopo/config.rb +73 -0
  22. data/lib/nswtopo/dither.rb +31 -0
  23. data/lib/nswtopo/font/chrome.rb +59 -0
  24. data/lib/nswtopo/font/generic.rb +25 -0
  25. data/lib/nswtopo/font.rb +43 -0
  26. data/lib/nswtopo/formats/kmz.rb +149 -0
  27. data/lib/nswtopo/formats/mbtiles.rb +64 -0
  28. data/lib/nswtopo/formats/pdf.rb +31 -0
  29. data/lib/nswtopo/formats/svg.rb +69 -0
  30. data/lib/nswtopo/formats/svgz.rb +13 -0
  31. data/lib/nswtopo/formats/zip.rb +40 -0
  32. data/lib/nswtopo/formats.rb +76 -0
  33. data/lib/nswtopo/geometry/overlap.rb +78 -0
  34. data/lib/nswtopo/geometry/r_tree.rb +47 -0
  35. data/lib/nswtopo/geometry/segment.rb +27 -0
  36. data/lib/nswtopo/geometry/straight_skeleton/collapse.rb +21 -0
  37. data/lib/nswtopo/geometry/straight_skeleton/interior_node.rb +17 -0
  38. data/lib/nswtopo/geometry/straight_skeleton/node.rb +50 -0
  39. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +295 -0
  40. data/lib/nswtopo/geometry/straight_skeleton/split.rb +33 -0
  41. data/lib/nswtopo/geometry/straight_skeleton/vertex.rb +9 -0
  42. data/lib/nswtopo/geometry/straight_skeleton.rb +6 -0
  43. data/lib/nswtopo/geometry/vector.rb +91 -0
  44. data/lib/nswtopo/geometry/vector_sequence.rb +179 -0
  45. data/lib/nswtopo/geometry.rb +8 -0
  46. data/lib/nswtopo/gis/arcgis_server/connection.rb +52 -0
  47. data/lib/nswtopo/gis/arcgis_server.rb +155 -0
  48. data/lib/nswtopo/gis/dem.rb +70 -0
  49. data/lib/nswtopo/gis/esri_hdr.rb +77 -0
  50. data/lib/nswtopo/gis/gdal_glob.rb +41 -0
  51. data/lib/nswtopo/gis/geojson/collection.rb +94 -0
  52. data/lib/nswtopo/gis/geojson/line_string.rb +11 -0
  53. data/lib/nswtopo/gis/geojson/multi_line_string.rb +63 -0
  54. data/lib/nswtopo/gis/geojson/multi_point.rb +12 -0
  55. data/lib/nswtopo/gis/geojson/multi_polygon.rb +167 -0
  56. data/lib/nswtopo/gis/geojson/point.rb +9 -0
  57. data/lib/nswtopo/gis/geojson/polygon.rb +11 -0
  58. data/lib/nswtopo/gis/geojson.rb +89 -0
  59. data/lib/nswtopo/gis/gps/gpx.rb +22 -0
  60. data/lib/nswtopo/gis/gps/kml.rb +66 -0
  61. data/lib/nswtopo/gis/gps.rb +20 -0
  62. data/lib/nswtopo/gis/projection.rb +56 -0
  63. data/lib/nswtopo/gis/shapefile.rb +24 -0
  64. data/lib/nswtopo/gis/world_file.rb +19 -0
  65. data/lib/nswtopo/gis.rb +9 -0
  66. data/lib/nswtopo/help_formatter.rb +59 -0
  67. data/lib/nswtopo/helpers/array.rb +30 -0
  68. data/lib/nswtopo/helpers/colour.rb +176 -0
  69. data/lib/nswtopo/helpers/concurrently.rb +27 -0
  70. data/lib/nswtopo/helpers/dir.rb +7 -0
  71. data/lib/nswtopo/helpers/hash.rb +15 -0
  72. data/lib/nswtopo/helpers/tar_writer.rb +11 -0
  73. data/lib/nswtopo/helpers.rb +6 -0
  74. data/lib/nswtopo/layer/arcgis_raster.rb +73 -0
  75. data/lib/nswtopo/layer/contour.rb +233 -0
  76. data/lib/nswtopo/layer/control.rb +94 -0
  77. data/lib/nswtopo/layer/declination.rb +53 -0
  78. data/lib/nswtopo/layer/feature.rb +87 -0
  79. data/lib/nswtopo/layer/grid.rb +120 -0
  80. data/lib/nswtopo/layer/import.rb +25 -0
  81. data/lib/nswtopo/layer/labels/fence.rb +20 -0
  82. data/lib/nswtopo/layer/labels.rb +630 -0
  83. data/lib/nswtopo/layer/overlay.rb +53 -0
  84. data/lib/nswtopo/layer/raster.rb +63 -0
  85. data/lib/nswtopo/layer/relief.rb +143 -0
  86. data/lib/nswtopo/layer/spot.rb +171 -0
  87. data/lib/nswtopo/layer/vector.rb +263 -0
  88. data/lib/nswtopo/layer/vegetation.rb +73 -0
  89. data/lib/nswtopo/layer.rb +78 -0
  90. data/lib/nswtopo/log.rb +28 -0
  91. data/lib/nswtopo/map.rb +296 -0
  92. data/lib/nswtopo/os.rb +75 -0
  93. data/lib/nswtopo/safely.rb +13 -0
  94. data/lib/nswtopo/version.rb +4 -0
  95. data/lib/nswtopo/zip.rb +15 -0
  96. data/lib/nswtopo.rb +249 -0
  97. metadata +142 -0
@@ -0,0 +1,63 @@
1
+ module NSWTopo
2
+ module Raster
3
+ def create
4
+ tif = Dir.mktmppath do |temp_dir|
5
+ tif_path = temp_dir / "final.tif"
6
+ tfw_path = temp_dir / "final.tfw"
7
+ out_path = temp_dir / "output.tif"
8
+
9
+ resolution, raster_path = get_raster(temp_dir)
10
+ dimensions, ppi, resolution = @map.raster_dimensions_at resolution: resolution
11
+ density = 0.01 * @map.scale / resolution
12
+ tiff_tags = %W[-mo TIFFTAG_XRESOLUTION=#{density} -mo TIFFTAG_YRESOLUTION=#{density} -mo TIFFTAG_RESOLUTIONUNIT=3]
13
+
14
+ @map.write_world_file tfw_path, resolution: resolution
15
+ OS.convert "-size", dimensions.join(?x), "canvas:none", "-type", "TrueColorMatte", "-depth", 8, tif_path
16
+ OS.gdalwarp "-t_srs", @map.projection, "-r", "bilinear", raster_path, tif_path
17
+ OS.gdal_translate "-a_srs", @map.projection, *tiff_tags, tif_path, out_path
18
+ @map.write filename, out_path.binread
19
+ end
20
+ end
21
+
22
+ def filename
23
+ "#{@name}.tif"
24
+ end
25
+
26
+ def empty?
27
+ false
28
+ end
29
+
30
+ def size_resolution
31
+ json = OS.gdalinfo "-json", "/vsistdin/" do |stdin|
32
+ stdin.binmode
33
+ stdin.write @map.read(filename)
34
+ end
35
+ size, geotransform = JSON.parse(json).values_at "size", "geoTransform"
36
+ resolution = geotransform.values_at(1, 2).norm
37
+ return size, resolution
38
+ end
39
+
40
+ def to_s
41
+ size, resolution = size_resolution
42
+ megapixels = size.inject(&:*) / 1024.0 / 1024.0
43
+ ppi = 0.0254 * @map.scale / resolution
44
+ "%s: %i×%i (%.1fMpx) @ %.1fm/px (%.0f ppi)" % [@name, *size, megapixels, resolution, ppi]
45
+ end
46
+
47
+ def render(group, defs)
48
+ (width, height), resolution = size_resolution
49
+ group.add_attributes "style" => "opacity:%s" % params.fetch("opacity", 1)
50
+ transform = "scale(#{1000.0 * resolution / @map.scale})"
51
+ png = Dir.mktmppath do |temp_dir|
52
+ tif_path = temp_dir / "raster.tif"
53
+ png_path = temp_dir / "raster.png"
54
+ tif_path.binwrite @map.read(filename)
55
+ OS.gdal_translate "-of", "PNG", "-co", "ZLEVEL=9", tif_path, png_path
56
+ png_path.binread
57
+ end
58
+ href = "data:image/png;base64,#{Base64.encode64 png}"
59
+ group.add_element "image", "transform" => transform, "width" => width, "height" => height, "image-rendering" => "optimizeQuality", "xlink:href" => href
60
+ group.add_attribute "mask", "url(#raster-mask)" if defs.elements["mask[@id='raster-mask']"]
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,143 @@
1
+ module NSWTopo
2
+ module Relief
3
+ include Raster, ArcGISServer, Shapefile, DEM, Log
4
+ CREATE = %w[altitude azimuth factor sources yellow smooth median bilateral contours]
5
+ DEFAULTS = YAML.load <<~YAML
6
+ altitude: 45
7
+ azimuth: 315
8
+ factor: 2.0
9
+ sources: 3
10
+ yellow: 0.2
11
+ smooth: 4
12
+ resolution: 5.0
13
+ opacity: 0.3
14
+ YAML
15
+
16
+ def margin
17
+ { mm: 3 * @smooth }
18
+ end
19
+
20
+ def get_raster(temp_dir)
21
+ dem_path = temp_dir / "dem.tif"
22
+ flat_relief = (Math::sin(@altitude * Math::PI / 180) * 255).to_i
23
+
24
+ case
25
+ when @path
26
+ get_dem temp_dir, dem_path
27
+
28
+ when @contours
29
+ bounds = @map.bounds(margin: margin)
30
+ txe, tye, spat = bounds[0], bounds[1].reverse, bounds.transpose.flatten
31
+ outsize = (bounds.transpose.difference / @resolution).map(&:ceil)
32
+
33
+ collection = @contours.map do |url_or_path, attribute_or_hash|
34
+ raise "no elevation attribute specified for #{url_or_path}" unless attribute_or_hash
35
+ options = Hash == attribute_or_hash ? attribute_or_hash.transform_keys(&:to_sym).slice(:where, :layer) : {}
36
+ attribute = Hash == attribute_or_hash ? attribute_or_hash["attribute"] : attribute_or_hash
37
+ case url_or_path
38
+ when ArcGISServer
39
+ arcgis_layer url_or_path, margin: margin, **options do |index, total|
40
+ log_update "%s: retrieved %i of %i contours" % [@name, index, total]
41
+ end
42
+ when Shapefile
43
+ shapefile_layer source_path, margin: margin, **options
44
+ else
45
+ raise "unrecognised elevation data source: #{url_or_path}"
46
+ end.each do |feature|
47
+ feature.properties.replace "elevation" => feature.fetch(attribute, attribute).to_f
48
+ end.reproject_to(@map.projection)
49
+ end.inject(&:merge)
50
+
51
+ log_update "%s: calculating DEM" % @name
52
+ OS.gdal_grid "-a", "linear:radius=0:nodata=-9999", "-zfield", "elevation", "-ot", "Float32", "-txe", *txe, "-tye", *tye, "-spat", *spat, "-outsize", *outsize, "/vsistdin/", dem_path do |stdin|
53
+ stdin.puts collection.to_json
54
+ end
55
+
56
+ else
57
+ raise "no elevation data specified for relief layer #{@name}"
58
+ end
59
+
60
+ log_update "%s: generating shaded relief" % @name
61
+ reliefs = -90.step(90, 90.0 / @sources).select.with_index do |offset, index|
62
+ index.odd?
63
+ end.map do |offset|
64
+ (@azimuth + offset) % 360
65
+ end.map do |azimuth|
66
+ relief_path = temp_dir / "relief.#{azimuth}.bil"
67
+ OS.gdaldem "hillshade", "-of", "EHdr", "-compute_edges", "-s", 1, "-alt", @altitude, "-z", @factor, "-az", azimuth, dem_path, relief_path
68
+ [azimuth, ESRIHdr.new(relief_path, 0)]
69
+ rescue OS::Error
70
+ raise "invalid elevation data"
71
+ end.to_h
72
+
73
+ bil_path = temp_dir / "relief.bil"
74
+ if reliefs.one?
75
+ reliefs.values.first.write bil_path
76
+ else
77
+ blur_path = temp_dir / "dem.blurred.tif"
78
+ blur_dem dem_path, blur_path
79
+
80
+ aspect_path = temp_dir / "aspect.bil"
81
+ OS.gdaldem "aspect", "-zero_for_flat", "-of", "EHdr", blur_path, aspect_path
82
+ aspect = ESRIHdr.new aspect_path, 0.0
83
+
84
+ log_update "%s: combining shaded relief" % @name
85
+ reliefs.map do |azimuth, relief|
86
+ [relief.values, aspect.values].transpose.map do |relief, aspect|
87
+ relief ? aspect ? 2 * relief * Math::sin((aspect - azimuth) * Math::PI / 180)**2 : relief : flat_relief
88
+ end
89
+ end.transpose.map do |values|
90
+ values.inject(&:+) / @sources
91
+ end.map do |value|
92
+ [255, value.ceil].min
93
+ end.tap do |values|
94
+ ESRIHdr.new(reliefs.values.first, values).write bil_path
95
+ end
96
+ end
97
+
98
+ tif_path = temp_dir / "relief.tif"
99
+ OS.gdalwarp "-co", "TFW=YES", "-s_srs", @map.projection, "-dstnodata", "None", bil_path, tif_path
100
+
101
+ filters = []
102
+ if @median
103
+ pixels = (2 * @median + 1).to_i
104
+ filters += %W[-channel RGBA -statistic median #{pixels}x#{pixels}]
105
+ end
106
+ if @bilateral
107
+ threshold, sigma = *@bilateral, (60.0 / @resolution).round
108
+ filters += %W[-channel RGB -selective-blur 0x#{sigma}+#{threshold}%]
109
+ end
110
+ if filters.any?
111
+ log_update "%s: applying filters" % @name
112
+ OS.mogrify "-virtual-pixel", "edge", *filters, tif_path
113
+ end
114
+
115
+ log_update "%s: rendering shaded relief" % @name
116
+ vrt_path = temp_dir / "coloured.vrt"
117
+ OS.gdalbuildvrt vrt_path, tif_path
118
+
119
+ xml = REXML::Document.new vrt_path.read
120
+ vrt_raster_band = xml.elements["VRTDataset/VRTRasterBand[ColorInterp[text()='Gray']]"]
121
+ vrt_raster_band.elements["ColorInterp[text()='Gray']"].text = "Palette"
122
+ color_table = vrt_raster_band.add_element "ColorTable"
123
+
124
+ shade, sun = 90 * flat_relief / 100, (10 + 90 * flat_relief) / 100
125
+ 256.times do |index|
126
+ case
127
+ when index < shade
128
+ color_table.add_element "Entry", "c1" => 0, "c2" => 0, "c3" => 0, "c4" => (shade - index) * 255 / shade
129
+ when index > sun
130
+ color_table.add_element "Entry", "c1" => 255, "c2" => 255, "c3" => 0, "c4" => ((index - sun) * 255 * @yellow / (255 - sun)).to_i
131
+ else
132
+ color_table.add_element "Entry", "c1" => 0, "c2" => 0, "c3" => 0, "c4" => 0
133
+ end
134
+ end
135
+
136
+ vrt_path.write xml
137
+ coloured_path = temp_dir / "coloured.tif"
138
+ OS.gdal_translate "-expand", "rgba", vrt_path, coloured_path
139
+ FileUtils.mv coloured_path, tif_path
140
+ return @resolution, tif_path
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,171 @@
1
+ module NSWTopo
2
+ module Spot
3
+ include Vector, DEM, Log
4
+ CREATE = %w[spacing smooth prefer]
5
+ DEFAULTS = YAML.load <<~YAML
6
+ spacing: 15
7
+ smooth: 0.2
8
+ symbol:
9
+ circle:
10
+ r: 0.2
11
+ stroke: none
12
+ fill: black
13
+ labels:
14
+ font-family: Arial, Helvetica, sans-serif
15
+ font-size: 1.4
16
+ margin: 0.7
17
+ position: [right, above, below, left, aboveright, belowright, aboveleft, belowleft]
18
+ YAML
19
+ NOISE_MM = 2.0 # TODO: noise sensitivity should depend on contour interval
20
+
21
+ def margin
22
+ { mm: 3 * @smooth }
23
+ end
24
+
25
+ def raster_values(path, pixels)
26
+ OS.gdallocationinfo "-valonly", path do |stdin|
27
+ pixels.each { |pixel| stdin.puts "%i %i" % pixel }
28
+ end.each_line.map do |line|
29
+ Float(line) rescue nil
30
+ end
31
+ end
32
+
33
+ def raster_locations(path, pixels)
34
+ OS.gdaltransform "-output_xy", path do |stdin|
35
+ pixels.each { |pixel| stdin.puts "%i %i" % pixel }
36
+ end.each_line.map do |line|
37
+ line.chomp.split(?\s).map(&:to_f)
38
+ end
39
+ end
40
+
41
+ module Candidate
42
+ module PreferKnolls
43
+ def ordinal; [conflicts.size, -self["elevation"]] end
44
+ end
45
+
46
+ module PreferSaddles
47
+ def ordinal; [conflicts.size, self["elevation"]] end
48
+ end
49
+
50
+ module PreferNeither
51
+ def ordinal; conflicts.size end
52
+ end
53
+
54
+ def conflicts
55
+ @conflicts ||= Set[]
56
+ end
57
+
58
+ def <=>(other)
59
+ self.ordinal <=> other.ordinal
60
+ end
61
+
62
+ def bounds(buffer = 0)
63
+ coordinates.map { |coordinate| [coordinate - buffer, coordinate + buffer] }
64
+ end
65
+ end
66
+
67
+ def ordering
68
+ @ordering ||= case @prefer
69
+ when "knolls" then Candidate::PreferKnolls
70
+ when "saddles" then Candidate::PreferSaddles
71
+ else Candidate::PreferNeither
72
+ end
73
+ end
74
+
75
+ def candidates
76
+ @candidates ||= Dir.mktmppath do |temp_dir|
77
+ raw_path = temp_dir / "raw.tif"
78
+ dem_path = temp_dir / "dem.tif"
79
+ aspect_path = temp_dir / "aspect.bil"
80
+
81
+ if @smooth.zero?
82
+ get_dem temp_dir, dem_path
83
+ else
84
+ get_dem temp_dir, raw_path
85
+ blur_dem raw_path, dem_path
86
+ end
87
+
88
+ log_update "%s: calculating aspect map" % @name
89
+ OS.gdaldem "aspect", dem_path, aspect_path, "-trigonometric"
90
+
91
+ Enumerator.new do |yielder|
92
+ aspect = ESRIHdr.new aspect_path, -9999
93
+ indices = [-1, 0, 1].map do |row|
94
+ [-1, 0, 1].map do |col|
95
+ row * aspect.ncols + col - 1
96
+ end
97
+ end.flatten.values_at(0,3,6,7,8,5,2,1,0)
98
+
99
+ aspect.nrows.times do |i|
100
+ log_update "%s: finding flat areas: %.1f%%" % [@name, 100.0 * i / aspect.nrows]
101
+ aspect.ncols.times do |j|
102
+ indices.map!(&:next)
103
+ next if i < 1 || j < 1 || i > aspect.nrows - 2 || j > aspect.ncols - 2
104
+ ring = aspect.values.values_at *indices
105
+ next if ring.any?(&:nil?)
106
+ anticlockwise = ring.each_cons(2).map do |a1, a2|
107
+ (a2 - a1) % 360 < 180
108
+ end
109
+ yielder << [[j + 1, i + 1], true] if anticlockwise.all?
110
+ yielder << [[j + 1, i + 1], false] if anticlockwise.none?
111
+ end
112
+ end
113
+ end.group_by(&:last).flat_map do |knoll, group|
114
+ pixels = group.map(&:first)
115
+ locations = raster_locations dem_path, pixels
116
+ elevations = raster_values dem_path, pixels
117
+
118
+ locations.zip(elevations).map do |coordinates, elevation|
119
+ GeoJSON::Point.new coordinates, "knoll" => knoll, "elevation" => elevation
120
+ end.each do |feature|
121
+ feature.extend Candidate, ordering
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ def get_features
128
+ selected, rejected, remaining = [], Set[], AVLTree.new
129
+ index = RTree.load(candidates, &:bounds)
130
+
131
+ log_update "%s: choosing candidates" % @name
132
+ candidates.to_set.each do |candidate|
133
+ buffer = NOISE_MM * @map.scale / 1000.0
134
+ index.search(candidate.bounds(buffer)).each do |other|
135
+ next unless candidate["knoll"] ^ other["knoll"]
136
+ next if [candidate, other].map(&:coordinates).distance > buffer
137
+ rejected << candidate << other
138
+ end
139
+ end.difference(rejected).each do |candidate|
140
+ buffer = @spacing * @map.scale / 1000.0
141
+ index.search(candidate.bounds(buffer)).each do |other|
142
+ next if other == candidate
143
+ next if rejected === other
144
+ next if [candidate, other].map(&:coordinates).distance > buffer
145
+ candidate.conflicts << other
146
+ end
147
+ end.each do |candidate|
148
+ remaining << candidate
149
+ end
150
+
151
+ while chosen = remaining.first
152
+ log_update "%s: choosing candidates: %i remaining" % [@name, remaining.count]
153
+ selected << chosen
154
+ removals = Set[chosen] | chosen.conflicts
155
+ removals.each do |candidate|
156
+ remaining.delete candidate
157
+ end.map(&:conflicts).inject(&:|).subtract(removals).each do |other|
158
+ remaining.delete other
159
+ other.conflicts.subtract removals
160
+ remaining.insert other
161
+ end
162
+ end
163
+
164
+ selected.each do |feature|
165
+ feature.properties.replace "label" => feature["elevation"].round
166
+ end.yield_self do |features|
167
+ GeoJSON::Collection.new @map.projection, features
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,263 @@
1
+ module NSWTopo
2
+ module Vector
3
+ SVG_ATTRIBUTES = %w[fill-opacity fill font-family font-size font-style font-variant font-weight letter-spacing opacity stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width stroke text-decoration visibility word-spacing]
4
+ FONT_SCALED_ATTRIBUTES = %w[word-spacing letter-spacing stroke-width line-height]
5
+ SHIELD_X, SHIELD_Y = 1.0, 0.5
6
+ MARGIN = { mm: 1.0 }
7
+ VALUE, POINT, ANGLE = "%.5f", "%.5f %.5f", "%.2f"
8
+
9
+ def create
10
+ @features = get_features.reproject_to(@map.projection).clip!(@map.bounding_box(MARGIN).coordinates.first)
11
+ @map.write filename, @features.to_json
12
+ end
13
+
14
+ def filename
15
+ "#{@name}.json"
16
+ end
17
+
18
+ def features
19
+ @features ||= GeoJSON::Collection.load @map.read(filename)
20
+ end
21
+
22
+ extend Forwardable
23
+ def_delegator :features, :none?, :empty?
24
+
25
+ def to_mm
26
+ @to_mm ||= @map.method(:coords_to_mm)
27
+ end
28
+
29
+ def drawing_features
30
+ features.explode.reject do |feature|
31
+ feature["draw"] == false
32
+ end
33
+ end
34
+
35
+ def labeling_features
36
+ features.select do |feature|
37
+ feature["label"]
38
+ end
39
+ end
40
+
41
+ def to_s
42
+ count = features.count
43
+ "%s: %i feature%s" % [@name, count, (?s unless count == 1)]
44
+ end
45
+
46
+ def categorise(string)
47
+ string.tr_s('^_a-zA-Z0-9', ?-).delete_prefix(?-).delete_suffix(?-)
48
+ end
49
+
50
+ def svg_path_data(points, bezier: false)
51
+ if bezier
52
+ fraction = Numeric === bezier ? bezier.clamp(0.0, 1.0) : 1.0
53
+ extras = points.first == points.last ? [points[-2], *points, points[2]] : [points.first, *points, points.last]
54
+ midpoints = extras.segments.map(&:midpoint)
55
+ distances = extras.segments.map(&:distance)
56
+ offsets = midpoints.zip(distances).segments.map(&:transpose).map do |segment, distance|
57
+ segment.along(distance.first / distance.inject(&:+))
58
+ end.zip(points).map(&:difference)
59
+ controls = midpoints.segments.zip(offsets).map do |segment, offset|
60
+ segment.map { |point| [point, point.plus(offset)].along(fraction) }
61
+ end.flatten(1).drop(1).each_slice(2).entries.prepend(nil)
62
+ points.zip(controls).map do |point, controls|
63
+ controls ? "C %s %s %s" % [POINT, POINT, POINT] % [*controls.flatten, *point] : "M %s" % POINT % point
64
+ end.join(" ")
65
+ else
66
+ points.map do |point|
67
+ POINT % point
68
+ end.join(" L ").prepend("M ")
69
+ end
70
+ end
71
+
72
+ def params_for(categories)
73
+ params.select do |key, value|
74
+ Array(key).any? do |selector|
75
+ String(selector).split(?\s).to_set <= categories
76
+ end
77
+ end.values.inject(params, &:merge)
78
+ end
79
+
80
+ def render(group, defs)
81
+ drawing_features.group_by do |feature, categories|
82
+ categories || Array(feature["category"]).map(&:to_s).map(&method(:categorise)).to_set
83
+ end.map do |categories, features|
84
+ dupes = params_for(categories)["dupe"]
85
+ Array(dupes).map(&:to_s).map do |dupe|
86
+ [categories | Set[dupe], [name, *categories, "content"].join(?.)]
87
+ end.push [categories, features]
88
+ end.flatten(1).map do |categories, features|
89
+ ids = [name, *categories]
90
+ case features
91
+ when String
92
+ container = group.add_element "use", "class" => categories.to_a.join(?\s), "xlink:href" => "#%s" % features
93
+ when Array
94
+ container = group.add_element "g", "class" => categories.to_a.join(?\s)
95
+ content = container.add_element "g", "id" => [*ids, "content"].join(?.)
96
+ end
97
+ container.add_attribute "id", ids.join(?.) if categories.any?
98
+
99
+ commands = params_for categories
100
+ font_size, bezier, section = commands.values_at "font-size", "bezier", "section"
101
+ commands.slice(*FONT_SCALED_ATTRIBUTES).each do |key, value|
102
+ commands[key] = commands[key].to_i * font_size * 0.01 if value =~ /^\d+%$/
103
+ end if font_size
104
+
105
+ features.each do |feature, _|
106
+ case feature
107
+ when GeoJSON::Point
108
+ symbol_id = [*ids, "symbol"].join(?.)
109
+ transform = "translate(%s) rotate(%s)" % [POINT, ANGLE] % [*feature.coordinates.yield_self(&to_mm), feature.fetch("rotation", @map.rotation) - @map.rotation]
110
+ content.add_element "use", "transform" => transform, "xlink:href" => "#%s" % symbol_id
111
+
112
+ when GeoJSON::LineString
113
+ linestring = feature.coordinates.map(&to_mm)
114
+ (section ? linestring.in_sections(section) : [linestring]).each do |linestring|
115
+ content.add_element "path", "fill" => "none", "d" => svg_path_data(linestring, bezier: bezier)
116
+ end
117
+
118
+ when GeoJSON::Polygon
119
+ path_data = feature.coordinates.map do |ring|
120
+ svg_path_data ring.map(&to_mm), bezier: bezier
121
+ end.join(" Z ").concat(" Z")
122
+ content.add_element "path", "fill-rule" => "nonzero", "d" => path_data
123
+
124
+ when REXML::Element
125
+ case feature.name
126
+ when "text", "textPath" then content << feature
127
+ when "path" then defs << feature
128
+ end
129
+
130
+ when Array
131
+ content.add_element "path", "fill" => "none", "d" => svg_path_data(feature + feature.take(1))
132
+ end
133
+ end if content
134
+
135
+ commands.each do |command, args|
136
+ next unless args
137
+ args = args.map(&:to_a).inject([], &:+) if Array === args && args.all?(Hash)
138
+
139
+ case command
140
+ when "blur"
141
+ filter_id = [*ids, "blur"].join(?.)
142
+ container.add_attribute "filter", "url(#%s)" % filter_id
143
+ defs.add_element("filter", "id" => filter_id).add_element "feGaussianBlur", "stdDeviation" => args, "in" => "SourceGraphic"
144
+
145
+ when "opacity"
146
+ if categories.none?
147
+ group.add_attribute "style", "opacity:#{args}"
148
+ else
149
+ container.add_attribute "opacity", args
150
+ end
151
+
152
+ when "symbol"
153
+ next unless content
154
+ symbol = defs.add_element "g", "id" => [*ids, "symbol"].join(?.)
155
+ args.each do |element, attributes|
156
+ symbol.add_element element, attributes
157
+ end
158
+
159
+ when "pattern"
160
+ dimensions, args = args.partition do |key, value|
161
+ %w[width height].include? key
162
+ end
163
+ width, height = Hash[dimensions].values_at "width", "height"
164
+ pattern_id = [*ids, "pattern"].join(?.)
165
+ pattern = defs.add_element "pattern", "id" => pattern_id, "patternUnits" => "userSpaceOnUse", "width" => width, "height" => height
166
+ args.each do |element, attributes|
167
+ pattern.add_element element, attributes
168
+ end
169
+ container.add_attribute "fill", "url(#%s)" % pattern_id
170
+
171
+ when "symbolise"
172
+ next unless content
173
+ interval, symbols = args.partition do |element, attributes|
174
+ element == "interval"
175
+ end
176
+ interval = Hash[interval]["interval"]
177
+ symbol_ids = symbols.map.with_index do |(element, attributes), index|
178
+ symbol_id = [*ids, "symbol", index].join(?.).tap do |symbol_id|
179
+ defs.add_element("g", "id" => symbol_id).add_element(element, attributes)
180
+ end
181
+ end
182
+ lines_or_rings = features.grep(GeoJSON::LineString).map(&:coordinates)
183
+ lines_or_rings += features.grep(GeoJSON::Polygon).map(&:coordinates).flatten(1)
184
+ lines_or_rings.each do |points|
185
+ points.map(&to_mm).sample_at(interval, angle: true).each do |point, angle|
186
+ transform = "translate(%s) rotate(%s)" % [POINT, ANGLE] % [*point, 180.0 * angle / Math::PI]
187
+ content.add_element "use", "transform" => transform, "xlink:href" => "#%s" % symbol_ids.sample
188
+ end
189
+ end
190
+
191
+ when "inpoint", "outpoint", "endpoint"
192
+ next unless content
193
+ symbol_id = [*ids, command].join(?.)
194
+ symbol = defs.add_element "g", "id" => symbol_id
195
+ args.each do |element, attributes|
196
+ symbol.add_element element, attributes
197
+ end
198
+ features.grep(GeoJSON::LineString).map do |feature|
199
+ feature.coordinates.map(&to_mm)
200
+ end.each do |line|
201
+ case command
202
+ when "inpoint" then [line.first(2)]
203
+ when "outpoint" then [line.last(2).rotate]
204
+ when "endpoint" then [line.first(2), line.last(2).rotate]
205
+ end.each do |segment|
206
+ transform = "translate(%s) rotate(%s)" % [POINT, ANGLE] % [*segment.first, 180.0 * segment.difference.angle / Math::PI]
207
+ container.add_element "use", "transform" => transform, "xlink:href" => "#%s" % symbol_id
208
+ end
209
+ end
210
+
211
+ when "mask"
212
+ next unless args && content && content.elements.any?
213
+ filter_id, mask_id = %w[raster-mask.filter raster-mask]
214
+ mask_contents = defs.elements["mask[@id='%s']/g[@filter]" % mask_id]
215
+ mask_contents ||= begin
216
+ defs.add_element("filter", "id" => filter_id).add_element "feColorMatrix", "type" => "matrix", "in" => "SourceGraphic", "values" => "0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 -1 1"
217
+ defs.add_element("mask", "id" => mask_id).add_element("g", "filter" => "url(#%s)" % filter_id).tap do |mask_contents|
218
+ mask_contents.add_element "rect", "width" => "100%", "height" => "100%", "fill" => "none", "stroke" => "none"
219
+ end
220
+ end
221
+ transforms = REXML::XPath.each(content, "ancestor::g[@transform]/@transform").map(&:value)
222
+ mask_contents.add_element "use", "xlink:href" => "#%s" % content.attributes["id"], "transform" => (transforms.join(?\s) if transforms.any?)
223
+
224
+ when "fence"
225
+ next unless content && args
226
+ buffer = 0.5 * (Numeric === args ? args : commands.fetch("stroke-width", 0))
227
+ features.each do |feature|
228
+ next if REXML::Element === feature
229
+ yield feature, buffer
230
+ end
231
+
232
+ when "shield"
233
+ next unless content
234
+ content.elements.each("text") do |element|
235
+ next unless text_length = element.elements["./ancestor-or-self::[@textLength]/@textLength"]&.value&.to_f
236
+ shield = REXML::Element.new("g")
237
+ width, height = text_length + SHIELD_X * font_size, (1 + SHIELD_Y) * font_size
238
+ shield.add_element "rect", "x" => -0.5 * width, "y" => -0.5 * height, "width" => width, "height" => height, "rx" => font_size * 0.3, "ry" => font_size * 0.3, "stroke" => "none", "fill" => args
239
+ text_transform = element.attributes.get_attribute "transform"
240
+ text_transform.remove
241
+ shield.attributes << text_transform
242
+ element.parent.elements << shield
243
+ shield << element
244
+ end
245
+
246
+ when *SVG_ATTRIBUTES
247
+ container.add_attribute command, args
248
+ end
249
+ end
250
+
251
+ next categories, features, container
252
+ end.tap do |categorised|
253
+ params.fetch("order", []).reverse.map(&:split).map(&:to_set).each do |filter|
254
+ categorised.select do |categories, features, container|
255
+ filter <= categories
256
+ end.reverse.each do |categories, features, container|
257
+ group.unshift container.remove
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end