nswtopo 2.0.0.pre.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/COPYING +674 -0
- data/bin/nswtopo +430 -0
- data/docs/README.md +78 -0
- data/docs/add.md +49 -0
- data/docs/config.md +24 -0
- data/docs/contours.md +37 -0
- data/docs/controls.md +9 -0
- data/docs/declination.md +15 -0
- data/docs/delete.md +15 -0
- data/docs/grid.md +5 -0
- data/docs/info.md +5 -0
- data/docs/init.md +38 -0
- data/docs/layers.md +11 -0
- data/docs/overlay.md +37 -0
- data/docs/relief.md +22 -0
- data/docs/render.md +43 -0
- data/docs/spot-heights.md +23 -0
- data/lib/nswtopo/archive.rb +93 -0
- data/lib/nswtopo/avl_tree.rb +128 -0
- data/lib/nswtopo/config.rb +73 -0
- data/lib/nswtopo/dither.rb +31 -0
- data/lib/nswtopo/font/chrome.rb +59 -0
- data/lib/nswtopo/font/generic.rb +25 -0
- data/lib/nswtopo/font.rb +43 -0
- data/lib/nswtopo/formats/kmz.rb +149 -0
- data/lib/nswtopo/formats/mbtiles.rb +64 -0
- data/lib/nswtopo/formats/pdf.rb +31 -0
- data/lib/nswtopo/formats/svg.rb +69 -0
- data/lib/nswtopo/formats/svgz.rb +13 -0
- data/lib/nswtopo/formats/zip.rb +40 -0
- data/lib/nswtopo/formats.rb +76 -0
- data/lib/nswtopo/geometry/overlap.rb +78 -0
- data/lib/nswtopo/geometry/r_tree.rb +47 -0
- data/lib/nswtopo/geometry/segment.rb +27 -0
- data/lib/nswtopo/geometry/straight_skeleton/collapse.rb +21 -0
- data/lib/nswtopo/geometry/straight_skeleton/interior_node.rb +17 -0
- data/lib/nswtopo/geometry/straight_skeleton/node.rb +50 -0
- data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +295 -0
- data/lib/nswtopo/geometry/straight_skeleton/split.rb +33 -0
- data/lib/nswtopo/geometry/straight_skeleton/vertex.rb +9 -0
- data/lib/nswtopo/geometry/straight_skeleton.rb +6 -0
- data/lib/nswtopo/geometry/vector.rb +91 -0
- data/lib/nswtopo/geometry/vector_sequence.rb +179 -0
- data/lib/nswtopo/geometry.rb +8 -0
- data/lib/nswtopo/gis/arcgis_server/connection.rb +52 -0
- data/lib/nswtopo/gis/arcgis_server.rb +155 -0
- data/lib/nswtopo/gis/dem.rb +70 -0
- data/lib/nswtopo/gis/esri_hdr.rb +77 -0
- data/lib/nswtopo/gis/gdal_glob.rb +41 -0
- data/lib/nswtopo/gis/geojson/collection.rb +94 -0
- data/lib/nswtopo/gis/geojson/line_string.rb +11 -0
- data/lib/nswtopo/gis/geojson/multi_line_string.rb +63 -0
- data/lib/nswtopo/gis/geojson/multi_point.rb +12 -0
- data/lib/nswtopo/gis/geojson/multi_polygon.rb +167 -0
- data/lib/nswtopo/gis/geojson/point.rb +9 -0
- data/lib/nswtopo/gis/geojson/polygon.rb +11 -0
- data/lib/nswtopo/gis/geojson.rb +89 -0
- data/lib/nswtopo/gis/gps/gpx.rb +22 -0
- data/lib/nswtopo/gis/gps/kml.rb +66 -0
- data/lib/nswtopo/gis/gps.rb +20 -0
- data/lib/nswtopo/gis/projection.rb +56 -0
- data/lib/nswtopo/gis/shapefile.rb +24 -0
- data/lib/nswtopo/gis/world_file.rb +19 -0
- data/lib/nswtopo/gis.rb +9 -0
- data/lib/nswtopo/help_formatter.rb +59 -0
- data/lib/nswtopo/helpers/array.rb +30 -0
- data/lib/nswtopo/helpers/colour.rb +176 -0
- data/lib/nswtopo/helpers/concurrently.rb +27 -0
- data/lib/nswtopo/helpers/dir.rb +7 -0
- data/lib/nswtopo/helpers/hash.rb +15 -0
- data/lib/nswtopo/helpers/tar_writer.rb +11 -0
- data/lib/nswtopo/helpers.rb +6 -0
- data/lib/nswtopo/layer/arcgis_raster.rb +73 -0
- data/lib/nswtopo/layer/contour.rb +233 -0
- data/lib/nswtopo/layer/control.rb +94 -0
- data/lib/nswtopo/layer/declination.rb +53 -0
- data/lib/nswtopo/layer/feature.rb +87 -0
- data/lib/nswtopo/layer/grid.rb +120 -0
- data/lib/nswtopo/layer/import.rb +25 -0
- data/lib/nswtopo/layer/labels/fence.rb +20 -0
- data/lib/nswtopo/layer/labels.rb +630 -0
- data/lib/nswtopo/layer/overlay.rb +53 -0
- data/lib/nswtopo/layer/raster.rb +63 -0
- data/lib/nswtopo/layer/relief.rb +143 -0
- data/lib/nswtopo/layer/spot.rb +171 -0
- data/lib/nswtopo/layer/vector.rb +263 -0
- data/lib/nswtopo/layer/vegetation.rb +73 -0
- data/lib/nswtopo/layer.rb +78 -0
- data/lib/nswtopo/log.rb +28 -0
- data/lib/nswtopo/map.rb +296 -0
- data/lib/nswtopo/os.rb +75 -0
- data/lib/nswtopo/safely.rb +13 -0
- data/lib/nswtopo/version.rb +4 -0
- data/lib/nswtopo/zip.rb +15 -0
- data/lib/nswtopo.rb +249 -0
- 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
|