class NSWTopo::Map

Attributes

centre[R]
extents[R]
projection[R]
rotation[R]
scale[R]

Public Class Methods

declination(longitude, latitude) click to toggle source
# File lib/nswtopo/map.rb, line 162
def self.declination(longitude, latitude)
  today = Date.today
  query = { lat1: latitude.abs, lat1Hemisphere: latitude < 0 ? ?S : ?N, lon1: longitude.abs, lon1Hemisphere: longitude < 0 ? ?W : ?E, model: "WMM", startYear: today.year, startMonth: today.month, startDay: today.day, resultFormat: "xml" }
  uri = URI::HTTPS.build host: "www.ngdc.noaa.gov", path: "/geomag-web/calculators/calculateDeclination", query: URI.encode_www_form(query)
  xml = Net::HTTP.get uri
  text = REXML::Document.new(xml).elements["//declination"]&.text
  text ? text.to_f : raise
rescue RuntimeError, SystemCallError, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, SocketError
  raise "couldn't get magnetic declination value"
end
init(archive, scale: 25000, rotation: 0.0, bounds: nil, coords: nil, dimensions: nil, margins: nil) click to toggle source
# File lib/nswtopo/map.rb, line 18
def self.init(archive, scale: 25000, rotation: 0.0, bounds: nil, coords: nil, dimensions: nil, margins: nil)
  wgs84_points = case
  when coords && bounds
    raise "can't specify both bounds file and map coordinates"
  when coords
    coords
  when bounds
    gps = GPS.load bounds
    margins ||= [15, 15] unless dimensions || gps.polygons.any?
    case
    when gps.polygons.any?
      gps.polygons.map(&:coordinates).flatten(1).inject(&:+)
    when gps.linestrings.any?
      gps.linestrings.map(&:coordinates).inject(&:+)
    when gps.points.any?
      gps.points.map(&:coordinates)
    else
      raise "no features found in %s" % bounds
    end
  else
    raise "no bounds file or map coordinates specified"
  end

  wgs84_centre = wgs84_points.transpose.map(&:minmax).map(&:sum).times(0.5)
  projection = Projection.azimuthal_equidistant *wgs84_centre

  case rotation
  when "auto"
    raise "can't specify both map dimensions and auto-rotation" if dimensions
    points = GeoJSON.multipoint(wgs84_points).reproject_to(projection).coordinates
    centre, extents, rotation = points.minimum_bounding_box(*margins)
    rotation *= -180.0 / Math::PI
  when "magnetic"
    rotation = declination(*wgs84_centre)
  else
    raise "map rotation must be between ±45°" unless rotation.abs <= 45
  end

  case
  when centre
  when dimensions
    raise "can't specify both margins and map dimensions" if margins
    extents = dimensions.map do |dimension|
      dimension * 0.001 * scale
    end
    centre = GeoJSON.point(wgs84_centre).reproject_to(projection).coordinates
  else
    points = GeoJSON.multipoint(wgs84_points).reproject_to(projection).coordinates
    centre, extents = points.map do |point|
      point.rotate_by_degrees rotation
    end.transpose.map(&:minmax).map do |min, max|
      [0.5 * (max + min), max - min]
    end.transpose
    centre.rotate_by_degrees! -rotation
  end

  wgs84_centre = GeoJSON.point(centre, projection: projection).reproject_to_wgs84.coordinates
  projection = Projection.transverse_mercator *wgs84_centre

  extents = extents.zip(margins).map do |extent, margin|
    extent + 2 * margin * 0.001 * scale
  end if margins

  case
  when extents.all?(&:positive?)
  when coords
    raise "not enough information to calculate map size – add more coordinates, or specify map dimensions or margins"
  when bounds
    raise "not enough information to calculate map size – check bounds file, or specify map dimensions or margins"
  end

  new(archive, proj4: projection.proj4, scale: scale, centre: [0, 0], extents: extents, rotation: rotation).save
end
load(archive) click to toggle source
# File lib/nswtopo/map.rb, line 92
def self.load(archive)
  new archive, **YAML.load(archive.read "map.yml")
end
new(archive, proj4:, scale:, centre:, extents:, rotation:, layers: {}) click to toggle source
# File lib/nswtopo/map.rb, line 5
def initialize(archive, proj4:, scale:, centre:, extents:, rotation:, layers: {})
  @archive, @scale, @centre, @extents, @rotation, @layers = archive, scale, centre, extents, rotation, layers
  @projection = Projection.new proj4
  ox, oy = bounding_box.coordinates[0][3]
  @affine = [[1, 0], [0, -1], [-ox, oy]].map do |vector|
    vector.rotate_by_degrees(-@rotation).times(1000.0 / @scale)
  end.transpose
end

Public Instance Methods

add(*layers, after: nil, before: nil, replace: nil, overwrite: false) click to toggle source
# File lib/nswtopo/map.rb, line 177
def add(*layers, after: nil, before: nil, replace: nil, overwrite: false)
  [%w[before after replace], [before, after, replace]].transpose.select(&:last).each do |option, name|
    next if self.layers.any? { |other| other.name == name }
    raise "no such layer: %s" % name
  end.map(&:first).combination(2).each do |options|
    raise OptionParser::AmbiguousOption,  "can't specify --%s and --%s simultaneously" % options
  end

  layers.inject [self.layers, false, replace || after, []] do |(layers, changed, follow, errors), layer|
    index = layers.index layer unless replace || after || before
    if overwrite || !layer.uptodate?
      layer.create
      log_success "%s layer: %s" % [layer.empty? ? "empty" : "added", layer.name]
    else
      log_neutral "kept existing layer: %s" % layer.name
      next layers, changed, layer.name, errors if index
    end
    layers.delete layer
    case
    when index
    when follow
      index = layers.index { |other| other.name == follow }
      index += 1
    when before
      index = layers.index { |other| other.name == before }
    else
      index = layers.index { |other| (other <=> layer) > 0 } || -1
    end
    next layers.insert(index, layer), true, layer.name, errors
  rescue ArcGISServer::Error, RuntimeError => error
    log_warn ArcGISServer::Error === error ? "couldn't download layer: #{layer.name}" : error.message
    next layers, changed, follow, errors << error
  end.tap do |ordered_layers, changed, follow, errors|
    if changed
      @layers.replace Hash[ordered_layers.map(&:pair)]
      replace ? delete(replace) : save
    end
    raise PartialFailureError, "failed to create %s" % [layers.one? ? "layer" : errors.one? ? "1 layer" : "#{errors.length} layers"] if errors.any?
  end
end
bounding_box(mm: nil, metres: nil) click to toggle source
# File lib/nswtopo/map.rb, line 116
def bounding_box(mm: nil, metres: nil)
  margin = mm ? mm * 0.001 * @scale : metres ? metres : 0
  ring = @extents.map do |extent|
    [-0.5 * extent - margin, 0.5 * extent + margin]
  end.inject(&:product).map do |offset|
    @centre.plus offset.rotate_by_degrees(-@rotation)
  end.values_at(0,2,3,1,0)
  GeoJSON.polygon [ring], projection: projection
end
bounds(margin: {}, projection: nil) click to toggle source
# File lib/nswtopo/map.rb, line 126
def bounds(margin: {}, projection: nil)
  bounding_box(margin).yield_self do |bbox|
    projection ? bbox.reproject_to(projection) : bbox
  end.coordinates.first.transpose.map(&:minmax)
end
coords_to_mm(point) click to toggle source
# File lib/nswtopo/map.rb, line 142
def coords_to_mm(point)
  @affine.map do |row|
    row.dot [*point, 1.0]
  end
end
declination() click to toggle source
# File lib/nswtopo/map.rb, line 173
def declination
  Map.declination *wgs84_centre
end
delete(*names) click to toggle source
# File lib/nswtopo/map.rb, line 218
def delete(*names)
  raise OptionParser::MissingArgument, "no layers specified" unless names.any?
  names.inject Set[] do |matched, name|
    matches = @layers.keys.grep(name)
    raise "no such layer: #{name}" if String === name && matches.none?
    matched.merge matches
  end.tap do |names|
    raise "no matching layers found" unless names.any?
  end.each do |name|
    params = @layers.delete name
    @archive.delete Layer.new(name, self, params).filename
    log_success "deleted layer: %s" % name
  end
  save
end
get_raster_resolution(raster_path) click to toggle source
# File lib/nswtopo/map.rb, line 148
def get_raster_resolution(raster_path)
  metre_diagonal = bounding_box.coordinates.first.values_at(0, 2)
  pixel_diagonal = OS.gdaltransform "-i", "-t_srs", @projection, raster_path do |stdin|
    metre_diagonal.each do |point|
      stdin.puts point.join(?\s)
    end
  end.each_line.map do |line|
    line.split(?\s).take(2).map(&:to_f)
  end
  metre_diagonal.distance / pixel_diagonal.distance
rescue OS::Error
  raise "invalid raster"
end
info(empty: nil) click to toggle source
# File lib/nswtopo/map.rb, line 234
def info(empty: nil)
  StringIO.new.tap do |io|
    io.puts "%-11s 1:%i" %            ["scale:",      @scale]
    io.puts "%-11s %imm × %imm" %     ["dimensions:", *@extents.times(1000.0 / @scale)]
    io.puts "%-11s %.1fkm × %.1fkm" % ["extent:",     *@extents.times(0.001)]
    io.puts "%-11s %.1fkm²" %         ["area:",       @extents.inject(&:*) * 0.000001]
    io.puts "%-11s %.1f°" %           ["rotation:",   @rotation]
    layers.reject(&empty ? :nil? : :empty?).inject("layers:") do |heading, layer|
      io.puts "%-11s %s" % [heading, layer]
      nil
    end
  end.string
end
Also aliased as: to_s
layers() click to toggle source
# File lib/nswtopo/map.rb, line 100
def layers
  @layers.map do |name, params|
    Layer.new(name, self, params)
  end
end
projwin(projection) click to toggle source
# File lib/nswtopo/map.rb, line 132
def projwin(projection)
  bounds(projection: projection).flatten.values_at(0,3,1,2)
end
raster_dimensions_at(ppi: nil, resolution: nil) click to toggle source
# File lib/nswtopo/map.rb, line 106
def raster_dimensions_at(ppi: nil, resolution: nil)
  resolution ||= 0.0254 * @scale / ppi
  ppi ||= 0.0254 * @scale / resolution
  return (@extents / resolution).map(&:ceil), ppi, resolution
end
render(*paths, worldfile: false, force: false, external: nil, **options) click to toggle source
# File lib/nswtopo/map.rb, line 249
def render(*paths, worldfile: false, force: false, external: nil, **options)
  @archive.delete "map.svg" if force
  Dir.mktmppath do |temp_dir|
    rasters = Hash.new do |rasters, opts|
      png_path = temp_dir / "raster.#{rasters.size}.png"
      pgw_path = temp_dir / "raster.#{rasters.size}.pgw"
      rasterise png_path, external: external, **opts
      write_world_file pgw_path, opts
      rasters[opts] = png_path
    end
    dithers = Hash.new do |dithers, opts|
      png_path = temp_dir / "dither.#{dithers.size}.png"
      pgw_path = temp_dir / "dither.#{dithers.size}.pgw"
      FileUtils.cp rasters[opts], png_path
      dither png_path
      write_world_file pgw_path, opts
      dithers[opts] = png_path
    end

    outputs = paths.map.with_index do |path, index|
      ext = path.extname.delete_prefix ?.
      name = path.basename(path.extname)
      out_path = temp_dir / "output.#{index}.#{ext}"
      send "render_#{ext}", out_path, name: name, external: external, **options do |dither: false, **opts|
        (dither ? dithers : rasters)[opts]
      end
      next out_path, path
    end

    safely "saving, please wait..." do
      outputs.each do |out_path, path|
        FileUtils.cp out_path, path
        log_success "created %s" % path
      end

      paths.select do |path|
        %w[.png .tif .jpg].include? path.extname
      end.group_by do |path|
        path.parent / path.basename(path.extname)
      end.keys.each do |base|
        write_world_file Pathname("#{base}.wld"), ppi: options.fetch(:ppi, Formats::PPI)
        Pathname("#{base}.prj").write "#{@projection}\n"
      end if worldfile
    end
  end
end
save() click to toggle source
# File lib/nswtopo/map.rb, line 96
def save
  tap { @archive.write "map.yml", YAML.dump(proj4: @projection.proj4, scale: @scale, centre: @centre, extents: @extents, rotation: @rotation, layers: @layers) }
end
to_s(empty: nil)
Alias for: info
wgs84_centre() click to toggle source
# File lib/nswtopo/map.rb, line 112
def wgs84_centre
  GeoJSON.point(@centre, projection: @projection).reproject_to_wgs84.coordinates
end
write_world_file(path, resolution: nil, ppi: nil) click to toggle source
# File lib/nswtopo/map.rb, line 136
def write_world_file(path, resolution: nil, ppi: nil)
  resolution ||= 0.0254 * @scale / ppi
  top_left = bounding_box.coordinates[0][3]
  WorldFile.write top_left, resolution, -@rotation, path
end