Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
547 lines (482 sloc) 19.7 KB
# Defines a map of various combinations of {Flight Flights} and {Airport
# Airports}, with the ability to generate {http://www.gcmap.com/ Great Circle
# Mapper}, {https://www.topografix.com/gpx.asp GPX}, and
# {https://developers.google.com/kml/ KML} maps.
#
# While Map can be used for class methods, instances of Map are not intended
# to be used directly (which is why it specifies no constructor method).
# Instead, the subclasses which provide specific map types should be used to
# create new maps.
#
# @see http://www.gcmap.com/ Great Circle Mapper
# @see https://www.topografix.com/gpx.asp GPX: the GPS Exchange Format
# @see https://www.topografix.com/gpx.asp Keyhole Markup Language
class Map
include ActionView::Helpers
include ActionView::Context
ICAO_REGIONS = {
"World": %w(),
"North America": %w(C K M T),
"Europe": %w(B E L),
"Pacific/Oceania": %w(A N PH R Y)
}
XML_PROLOG = %Q(<?xml version="1.0" encoding="UTF-8" ?>).html_safe
# Creates HTML for a {http://www.gcmap.com/ Great Circle Mapper} map.
#
# @return [ActiveSupport::SafeBuffer] HTML for a {http://www.gcmap.com/ Great
# Circle Mapper} map.
# @see http://www.gcmap.com/ Great Circle Mapper
def gcmap
return content_tag(:div, class: "center") do
concat link_to(image_tag(Rails.application.routes.url_helpers.gcmap_image_path(gcmap_airport_options, gcmap_query.gsub("/","_"), Map.hash_image_query(gcmap_query)), alt: map_description, class: "map"), "http://www.gcmap.com/mapui?PM=#{gcmap_airport_options}&MP=r&MS=wls2&P=#{gcmap_query}", target: "_blank")
concat content_tag(:div, ActiveSupport::SafeBuffer.new + "Map generated by " + link_to("Great Circle Mapper", "http://www.gcmap.com/", target: "_blank"), class: %w(credit map-credit))
end
end
# Checks whether or not this map contains enough data to create a
# {http://www.gcmap.com/ Great Circle Mapper} map.
#
# @return [Boolean] whether or not this map has a non-blank
# {http://www.gcmap.com/ Great Circle Mapper} querystring
# @see http://www.gcmap.com/ Great Circle Mapper
def gcmap_exists?
gcmap_query.present?
end
# Returns all regions contained in the current map. Used for generating
# region selection tabs above a {http://www.gcmap.com/ Great Circle Mapper}
# map image.
#
# Regions are defined by an array of ICAO code prefixes (e.g. ["K","PH"]).
# {Airport Airports} are considered to be in a region if their ICAO code starts with
# any of the elements of the prefix array (e.g. KATL, PHNL), and {Flight Flights}
# are considered to be in a region if _both_ of their airports are in the
# region.
#
# @param selected_region [Array] an array of ICAO prefixes (e.g. ["K","PH"]).
# @return [Hash{String => Boolean, Array}] name and ICAO prefixes for each
# region, and a boolean indicating whether this region's button should be
# selected (true if this region is the currently selected region) in the
# format !{"Europe": {selected: false, icao: ["B","E","L"]}}
# @see ApplicationHelper#gcmap_with_region_select
# @see http://www.gcmap.com/ Great Circle Mapper
def gcmap_regions(selected_region)
@airport_details ||= airport_details
used_airports = @airport_details.keys
region_hash = Hash.new
ICAO_REGIONS.each do |name, icao|
if selected_region.uniq.sort == icao.uniq.sort
region_hash[name] = {selected: true}
else
in_region = Airport.in_region_ids(icao).sort
if ((in_region & used_airports).any? && (used_airports - in_region).any?) || icao == []
# This region has airports, but is not identical to world OR this region is world.
region_hash[name] = {selected: false, icao: icao}
end
end
end
return region_hash
end
# Creates XML for a {https://www.topografix.com/gpx.asp GPX} map.
#
# @return [ActiveSupport::Safebuffer] XML for a
# {https://www.topografix.com/gpx.asp GPX} map.
# @see https://www.topografix.com/gpx.asp GPX: the GPS Exchange Format
def gpx
@airport_details ||= airport_details
used_airports = @airport_details.keys
output = XML_PROLOG
output += content_tag(:gpx, xmlns: "http://www.topografix.com/GPX/1/1", version: "1.1") do
concat (content_tag(:metadata) do
concat content_tag(:name, map_name)
concat content_tag(:desc, map_description)
concat (content_tag(:author) do
concat content_tag(:name, "Paul Bogard’s Flight Historian")
concat content_tag(:link, content_tag(:text, "Paul Bogard’s Flight Historian"), href: "https://www.flighthistorian.com")
end)
end)
concat gpx_airports(used_airports)
concat gpx_routes(routes_normal | routes_out_of_region | routes_highlighted | routes_unhighlighted)
end
return output
end
# Creates XML for a {https://developers.google.com/kml/ KML} map.
#
# @return [ActiveSupport::Safebuffer] XML for a
# {https://developers.google.com/kml/ KML} map.
# @see https://www.topografix.com/gpx.asp Keyhole Markup Language
def kml
@airport_details ||= airport_details
used_airports = @airport_details.keys
output = XML_PROLOG
output += content_tag(:kml, xmlns: "http://www.opengis.net/kml/2.2") do
content_tag(:Document) do
concat content_tag(:name, map_name)
concat content_tag(:description, map_description)
concat kml_styles
concat kml_airports(used_airports)
concat kml_routes(routes_normal | routes_out_of_region, "Routes")
concat kml_routes(routes_highlighted, "Highlighted Routes")
concat kml_routes(routes_unhighlighted, "Unhighlighted Routes")
end
end
return output
end
# Creates a hash of a map query based on a secret key.
#
# @param query [String] the query to hash
# @return [String] a hash of the query
# @see PagesController#gcmap_image_proxy
def self.hash_image_query(query)
Digest::MD5.hexdigest(query + ENV["IMAGE_KEY"])
end
private
# Creates a hash for looking up airport details by airport ID.
#
# @return [Hash{Number => Number, Number, String, String, String, String}] a
# hash of airport details in the form of {airport_id => {latitude: 0,
# longitude: 0, city: "City", country: "Country", iata: "AAA", icao:
# "AAAA"}}.
def airport_details
details = Hash.new
airport_ids = Array.new
airport_ids |= airports_normal
airport_ids |= airports_highlighted
airport_ids |= airports_out_of_region
airport_ids |= routes_normal.flatten
airport_ids |= routes_highlighted.flatten
airport_ids |= routes_unhighlighted.flatten
airport_ids |= routes_out_of_region.flatten
airport_ids = airport_ids.uniq.sort
airports = Airport.where(id: airport_ids)
airports.each do |airport|
details[airport.id] = {iata: airport.iata_code, icao: airport.icao_code, latitude: airport.latitude, longitude: airport.longitude, city: airport.city, country: airport.country, visits: airport_frequencies[airport.id]}
end
return details
end
# Returns an array of airport IDs for airports with no special formatting.
#
# @return [Array<Number>] airport IDs
def airports_normal
return Array.new
end
# Returns an array of airport IDs for airports that should be emphasized.
#
# @return [Array<Number>] airport IDs
def airports_highlighted
return Array.new
end
# Returns an array of airport IDs for airports that are not in the current
# region.
#
# @return [Array<Number>] airport IDs
def airports_out_of_region
return Array.new
end
# Create a hash for looking up the number of times an airport has been
# visited by airport ID.
#
# @return [Hash{Number => Number}] a hash of airport frequencies in the form
# of {airport_id => frequency}
def airport_frequencies
return Hash.new
end
# Creates an array of numerically-sorted pairs of airport IDs for routes with
# no special formatting.
#
# @return [Array<Array>] an array of routes in the form of [[airport_1_id,
# airport_2_id]].
def routes_normal
return Array.new
end
# Creates an array of numerically-sorted pairs of airport IDs for routes that
# should be emphasized.
#
# @return [Array<Array>] an array of routes in the form of [[airport_1_id,
# airport_2_id]].
def routes_highlighted
return Array.new
end
# Creates an array of numerically-sorted pairs of airport IDs for routes that
# should be de-emphasized.
#
# @return [Array<Array>] an array of routes in the form of [[airport_1_id,
# airport_2_id]].
def routes_unhighlighted
return Array.new
end
# Creates an array of numerically-sorted pairs of airport IDs for routes that
# are not in the current region.
#
# @return [Array<Array>] an array of routes in the form of [[airport_1_id,
# airport_2_id]].
def routes_out_of_region
return Array.new
end
# Create a hash for looking up the number of times an route has been flown
# by numerically-sorted pairs of airport ID.
#
# @return [Hash{Array<Number, Number> => Number}] a hash of route frequencies
# in the form of {[airport_1_id, airport_2_id] => frequency}.
def route_frequencies
return Hash.new
end
# Returns the map name.
#
# @return [String] the map name
def map_name
return "Flights"
end
# Returns the map description.
#
# @return [String] the map description
def map_description
return "Map of flight routes, created by Paul Bogard’s Flight Historian"
end
# GREAT CIRCLE MAPPER METHODS
# Returns Great Circle Mapper airport options.
#
# @return [String] Great Circle Mapper airport options
def gcmap_airport_options
return "b:disc5:black"
end
# Returns true if highlighted airports should display names, false otherwise.
#
# @return [Boolean] whether or not highlighted airports should display names
def gcmap_include_highlighted_airport_names?
return false
end
# Creates a Great Circle Mapper querystring based on the airports, routes,
# and options associated with this Map instance.
#
# @return [String] a Great Circle Mapper querystring
def gcmap_query
@airport_details ||= airport_details
query_sections = Array.new
if routes_out_of_region.any? || routes_unhighlighted.any?
query_sections.push("c:%23FF7777")
# Add routes outside region:
if routes_out_of_region.any?
query_sections.push(gcmap_route_string(routes_out_of_region, noext: true))
end
# Add unhighlighted routes:
if routes_unhighlighted.any?
query_sections.push(gcmap_route_string(routes_unhighlighted))
end
end
if routes_normal.any? || routes_highlighted.any?
query_sections.push("c:red")
# Add routes inside region:
if routes_normal.any?
query_sections.push(gcmap_route_string(routes_normal))
end
# Add highlighted routes:
if routes_highlighted.any?
query_sections.push("w:2")
query_sections.push(gcmap_route_string(routes_highlighted))
end
end
# Add airports:
if airports_normal.any?
query_sections.push(gcmap_airport_string(airports_normal))
end
# Add highlighted airports:
if airports_highlighted.any?
if gcmap_include_highlighted_airport_names?
query_sections.push(%Q(m:p:ring11:black%2B"%25N"12r%3A%23666))
else
query_sections.push("m:p:ring11:black")
end
query_sections.push(gcmap_airport_string(airports_highlighted))
end
# Add frequency rings:
if airport_frequencies.any?
query_sections.push(gcmap_airport_frequency_rings_string(airport_frequencies))
end
if query_sections.length > 0
return query_sections.join(",")
else
return " "
end
end
# Creates a Great Circle Mapper querystring from a list of airports.
#
# @param airports [Array<Number>] airport IDs
# @return [String] a comma-separated string of IATA codes
def gcmap_airport_string(airports)
return airports.map{|a| @airport_details[a][:iata]}.join(",")
end
# Create an array of IATA codes preceeded by appropriate Great Circle
# Mapper-formatted airport disc sizes.
#
# @param frequencies [Hash{Number => Number}] A hash with airport IDs for
# keys and number of visits for values
# @return [String] a Great Circle Mapper querystring
def gcmap_airport_frequency_rings_string(frequencies)
max_gcmap_ring = 99 # Define the maximum ring size gcmap will allow
previous_airport_value = nil
frequency_max = 1.0
frequency_scaled = 0
query = Array.new
region_frequencies = Array.new
airports_normal.each do |airport|
region_frequencies.push(iata_code: @airport_details[airport][:iata], frequency: frequencies[airport])
end
region_frequencies.sort_by! { |airport| [-airport[:frequency], airport[:iata_code]] }
region_frequencies.each do |airport|
if airport == region_frequencies.first
# This is the first circle, so define its color:
query.push("m:p:ring#{max_gcmap_ring}:black")
query.push(airport[:iata_code])
frequency_max = airport[:frequency].to_f
elsif airport[:frequency] == previous_airport_value
# Value is the same as previous, so no need to define ring size:
query.push(airport[:iata_code])
else
frequency_scaled = Math.sqrt((airport[:frequency].to_f / frequency_max)*(max_gcmap_ring**2)).ceil.to_i # Scale frequency range from 1..max_gcmap_ring
query.push("m:p:ring#{frequency_scaled}")
query.push(airport[:iata_code])
end
previous_airport_value = airport[:frequency]
end
return query.join(",")
end
# Create a Great Circle Mapper querystring from a list of routes.
#
# @param routes [Array<Array>] an array of routes in the form of
# [[airport_1_id, airport_2_id]]
# @option [Boolean] :noext (false) whether or not to apply the Great
# Circle Mapper "noext" option to these routes, which means they're not
# considered when defining the latitude and longitude ranges of the map
# @return [String] comma-separated sets of hyphen-separated IATA code pairs
def gcmap_route_string(routes, noext: false)
# Generate an array of airport IDs, sorted by most used to least used:
frequency_order = routes.flatten.each_with_object(Hash.new(0)){|key, hash| hash[key] += 1}.sort_by{|k,v| -v}.map{|x| x[0]}
route_groups = Array.new
# Loop through ordered airport IDs and generate gcmap querystring for it
frequency_order.each do |airport_id|
break if routes.empty?
# Save all routes with this airport id to matching, and retain only the routes that don't match:
matching, routes = routes.partition{|x| x[0] == airport_id || x[1] == airport_id}
if matching.any?
# Create querystring:
route_groups.push("o:noext") if noext
route_groups.push(@airport_details[airport_id][:iata] + "-" + matching.map{|x| @airport_details[x[0] == airport_id ? x[1] : x[0]][:iata]}.sort.join("/")) # The map with ternary statement is used to ensure we keep routes where both airports are the same; otherwise we could just use flatten and reject.
end
end
return route_groups.join(",")
end
# GPX METHODS
# Create a GPX waypoint for a specific airport.
#
# @param airport_id [Number] an airport ID
# @param wpt_type [Symbol] the GPX waypoint type to use (e.g. :wpt, :rtept,
# :trkpt)
# @return [ActiveSupport::SafeBuffer] XML for a waypoint
def gpx_airport(airport_id, wpt_type)
detail = @airport_details[airport_id]
return content_tag(wpt_type, lat: detail[:latitude], lon: detail[:longitude]) do
concat content_tag(:name, detail[:iata] + " / " + detail[:icao])
concat content_tag(:description, detail[:city])
end
end
# Create GPX waypoints for a collection of airports.
#
# @param airports [Array<Number>] airport IDs
# @return [ActiveSupport::SafeBuffer] XML for multiple waypoints
def gpx_airports(airports)
airports = airports.sort_by{|a| @airport_details[a][:iata]}
return safe_join(airports.map{|a| gpx_airport(a, :wpt)})
end
# Create a specific GPX route.
#
# @param airport_pair [Array<Number>] two airport IDs
# @return [ActiveSupport::SafeBuffer] XML for a route
def gpx_route(airport_pair)
detail = airport_pair.map{|a| @airport_details[a]}
return content_tag(:rte) do
concat content_tag(:name, detail.map{|a| a[:iata]}.join(""))
concat content_tag(:desc, detail.map{|a| a[:city]}.join(""))
concat content_tag(:link, nil, href: "https://www.flighthistorian.com/routes/#{detail[0][:iata]}-#{detail[1][:iata]}")
concat safe_join(airport_pair.map{|a| gpx_airport(a, :rtept)})
end
end
# Create a collection of GPX routes.
#
# @param routes [Array<Array>] an array of routes in the form of
# [[airport_1_id, airport_2_id]]
# @return [ActiveSupport::SafeBuffer] XML for multiple routes
def gpx_routes(routes)
return nil unless routes.any?
routes = routes.map{|r| r.sort_by{|x| @airport_details[x][:iata]}}.uniq.sort_by{|y| [@airport_details[y[0]][:iata], @airport_details[y[1]][:iata]]}
return safe_join(routes.map{|r| gpx_route(r)})
end
# KML METHODS
# Create KML for a specific airport.
#
# @param airport_id [Number] an airport ID
# @return [ActiveSupport::SafeBuffer] XML for a Placemark
def kml_airport(airport_id)
detail = @airport_details[airport_id]
return content_tag(:Placemark) do
concat content_tag(:name, detail[:iata] + " / " + detail[:icao])
concat content_tag(:description, detail[:city])
concat content_tag(:styleUrl, "#airportMarker")
concat content_tag(:Point, content_tag(:coordinates, "#{detail[:longitude]},#{detail[:latitude]},0"))
end
end
# Create KML for a collection of airports.
#
# @param airports [Array<Number>] airport IDs
# @return [ActiveSupport::SafeBuffer] XML for a folder of Placemarks
def kml_airports(airports)
airports = airports.sort_by{|a| @airport_details[a][:iata]}
return content_tag(:Folder) do
concat content_tag(:name, "Airports")
concat safe_join(airports.map{|a| kml_airport(a)})
end
end
# Create KML for a specific route.
#
# @param airport_pair [Array<Number>] two airport IDs
# @return [ActiveSupport::SafeBuffer] XML for a LineString
def kml_route(airport_pair)
detail = airport_pair.map{|a| @airport_details[a]}
return content_tag(:Placemark) do
concat content_tag(:name, detail.map{|a| a[:iata]}.join(""))
concat content_tag(:styleUrl, "#flightPath")
concat (content_tag(:LineString) do
concat content_tag(:tessellate, "1")
concat content_tag(:coordinates, detail.map{|a| "#{a[:longitude]},#{a[:latitude]},0"}.join(" "))
end)
end
end
# Create KML for a collection of routes.
#
# @param routes [Array<Number>] an array of routes in the form of
# [[airport_1_id, airport_2_id]]
# @param name [String] the folder name
# @return [ActiveSupport::SafeBuffer] XML for a folder of LineStrings
def kml_routes(routes, name)
return nil unless routes.any?
routes = routes.map{|r| r.sort_by{|x| @airport_details[x][:iata]}}.uniq.sort_by{|y| [@airport_details[y[0]][:iata], @airport_details[y[1]][:iata]]}
return content_tag(:Folder) do
concat content_tag(:name, name)
concat safe_join(routes.map{|r| kml_route(r)})
end
end
# Define KML styles.
#
# @return [ActiveSupport::SafeBuffer] XML for KML styles
def kml_styles
output = content_tag(:Style, id: "airportMarker") do
content_tag(:Icon) do
content_tag(:href, "http://maps.google.com/mapfiles/kml/shapes/placemark_circle.png")
end
end
output += content_tag(:Style, id: "flightPath") do
content_tag(:LineStyle) do
concat content_tag(:color, "ff0000ff")
concat content_tag(:width, "2")
end
end
end
end
You can’t perform that action at this time.