Source: Directions.js

/* global google */
/**
 * @module nyc/Directions
 */

import $ from 'jquery'

import Contanier from 'nyc/Container'
import Dialog from 'nyc/Dialog'
import Tabs from 'nyc/Tabs'
import TripPlanHack from 'nyc/mta/TripPlanHack'
import nyc from 'nyc'

const proj4 = nyc.proj4

/**
 * @desc Provides directions using google maps
 * @public
 * @class
 * @extends {module:nyc/Contanier~Contanier}
 * @constructor
 */
class Directions extends Contanier {
  /**
   * @desc Provides directions using google maps
   * @public
   * @constructor
   * @param {module:nyc/Directions~Directions.Options=} options Constructor options
   */
  constructor(options) {
    super('body')
    options = options || {}
    global.directions = this
    this.append(Directions.HTML)
    /**
     * @private
     * @member {module:nyc/Tabs~Tabs}
     */
    this.tabs = new Tabs({
      target: '#dir-tabs',
      tabs: [
        {tab: '#map-tab', title: 'Route Map'},
        {tab: '#route-tab', title: 'Directions'}
      ]
    })
    this.tabs.find('.btn-0').attr('aria-hidden', true)
    this.tabs.on('change', this.tabChange, this)
    /**
     * @public
     * @member {google.maps.Map}
     */
    this.map = null
    /**
     * @private
     * @member {google.maps.DirectionsService}
     */
    this.service = null
    /**
     * @private
     * @member {google.maps.DirectionsRenderer}
     */
    this.renderer = null
    /**
     * @private
     * @member {google.maps.Marker}
     */
    this.marker = null
    /**
     * @private
     * @member {google.maps.InfoWindow}
     */
    this.infoWin = null
    /**
     * @private
     * @member {module:nyc/Directions~Directions.Request}
     */
    this.args = null
    /**
     * @private
     * @member {Element}
     */
    this.modeBtn = $('#transit').get(0)
    /**
     * @private
     * @member {string}
     */
    this.url = `${(options.url || Directions.GOOGLE_URL)}&callback=directions.init`
    /**
     * @private
     * @member {jQuery.Event}
     */
    this.routeTarget = this.find('#route-tab div.route')
    /**
     * @private
     * @member {string}
     */
    this.lastDir = ''
    /**
     * @private
     * @member {jQuery}
     */
    this.toggle = $(options.toggle)
    /**
     * @private
     * @member {Array<Object<string, Object>>}
     */
    this.styles = options.styles || Directions.DEFAULT_STYLES
    /**
     * @private
     * @member {boolean}
     */
    this.monitoring = false
    /**
     * @private
     * @member {google.maps.TravelMode}
     */
    this.defaultMode = options.mode || 'TRANSIT'
    $('#mta').click($.proxy(this.tripPlanHack, this))
    $('#mode button').not('#mta').click($.proxy(this.mode, this))

    const input = $('#fld-from input')
    input.keypress($.proxy(this.key, this)).focus(() => input.select())

    const tog = this.toggle
    $('#back-to-map').click(() => {
      $('#directions').slideUp()
      tog.show().attr('aria-hidden', false)
    })
  }
  /**
   * @desc Get directions
   * @public
   * @method
   * @param {module:nyc/Directions~Directions.Request} args The arguments describing the requested directions
   * @return {jqXHR|undefined} JQuery XHR object
   */
  directions(args) {
    const mode = args.mode || this.defaultMode
    const url = this.url
    const tog = this.toggle
    this.modeBtn = $(`[data-mode="${mode}"]`)
    this.modeAria()
    this.args = args
    this.monitor()
    this.tabs.open('#route-tab')
    if (!this.map) {
      return $.getScript(url)
    }
    args.from = args.from || $('#fld-from input').val()
    $('#fld-from input').val(args.from)
    $('#fld-to').html(args.to)
    $('#fld-facility').html(args.facility)
    $('#directions').slideDown(() => {
      tog.hide()
    })
    tog.attr('aria-hidden', true)
    this.createMarker(this.getLatLng())
    if (args.from && this.lastDir !== `${args.from}|${args.to}|${mode}`) {
      this.lastDir = `${args.from}|${args.to}|${mode}`
      this.service.route(
        {
          origin: args.from,
          destination: args.to,
          travelMode: google.maps.TravelMode[mode]
        },
        $.proxy(this.handleResp, this)
      )
    }
    $('#back-to-map').one('click', () => {
      $(args.returnFocus).focus()
    })
  }
  /**
   * @private
   * @method
   */
  monitor() {
    if (!this.monitoring) {
      const fn = $.proxy(this.routeAlt, this)
      this.monitoring = true
      if ('MutationObserver' in window) {
        new MutationObserver(fn)
          .observe(this.find('.route').get(0), {childList: true})
      } else {
        setInterval(fn, 500)
      }
    }
  }
  /**
   * @private
   * @method
   * @param {Object} response Response object
   * @param {string} status Response status
   */
  handleResp(response, status) {
    const target = $(this.routeTarget)
    if (status === google.maps.DirectionsStatus.OK) {
      this.marker.setMap(null)
      const leg = response.routes[0].legs[0]
      const addrA = leg.start_address.replace(/\, USA/, '')
      const addrB = leg.end_address.replace(/\, USA/, '')
      if (!this.args.origin.coordinate) {
        const start = leg.start_location
        this.args.origin = {
          name: addrA,
          coordinate: [start.lng(), start.lat()],
          projection: 'EPSG:4326'
        }
      }
      this.renderer.setOptions({
        map: this.map,
        panel: target.get(0),
        directions: response
      })
      $('#fld-from input').val(addrA)
      $('#fld-to').html(addrB)
    } else {
      target.empty()
      new Dialog().ok({
        message: `Could not determine directions from '${this.args.from}' to '${this.args.to}'`
      })
    }
    this.trigger('change', {response: response, status: status})
  }
  /**
   * @private
   * @method
   */
  routeAlt() {
    let first = true
    let hasTransit = false
    global.directions.find('.route img').each((_, img) => {
      const src = img.src
      let imgName = src.match(/([^\/]+)(?=\.\w+$)/)
      imgName = imgName ? imgName[0] : ''
      imgName = imgName.replace(/X/, ' Express')
      if (src.indexOf('us-ny-mta') > -1) {
        hasTransit = true
        img.alt = `Take the ${imgName} train `
      } else if (imgName.indexOf('rail') > -1) {
        hasTransit = true
        img.alt = 'Take the train '
      } else if (imgName.indexOf('bus') > -1) {
        hasTransit = true
        img.alt = 'Take the bus '
      } else if (imgName === 'walk') {
        img.alt = 'Walk '
      } else if ($(img).hasClass('adp-marker2')) {
        img.alt = first ? 'Start location ' : 'End location '
        first = false
      }
    })
    this.find('.no-trans')[!hasTransit && this.modeBtn.id === 'transit' ? 'show' : 'hide']()
  }
  /**
   * @private
   * @method
   */
  tabChange() {
    if (this.renderer) {
      this.renderer.setOptions({map: this.map})
    }
  }
  /**
   * @desc Initializes the class on callback from the Google Maps
   * @public
   * @method
   */
  init() {
    const destination = this.getLatLng()
    this.map = new google.maps.Map($('#map-tab div.map').get(0), {
      mapTypeId: google.maps.MapTypeId.ROADMAP,
      backgroundColor: '#D3D3D3',
      panControl: false,
      streetViewControl: false,
      mapTypeControl: false,
      zoomControl: false,
      maxZoom: 18,
      zoom: 17,
      center: destination,
      styles: this.styles
    })
    this.service = new google.maps.DirectionsService()
    this.renderer = new google.maps.DirectionsRenderer()
    this.find('.btn-z-in, .btn-z-out').click($.proxy(this.zoom, this))
    this.directions(this.args)
  }
  /**
   * @private
   * @method
   * @param {google.maps.LatLngLiteral} destination The destination location
   */
  createMarker(destination) {
    const text = this.args.to.replace(/\n/g, ' ')
    if (this.marker) {
      this.marker.setMap(null)
    }
    this.infoWin = this.infoWin || new google.maps.InfoWindow()
    this.infoWin.setContent(`<div class="gm-iw">${text}</div>`)
    this.marker = new google.maps.Marker({
      position: destination,
      map: this.map,
      label: {
        text: 'B',
        color: 'white',
        fontSize: '16px'
      },
      title: text
    })
    this.marker.addListener('click', $.proxy(this.openInfoWin, this));
    this.map.setCenter(destination)
  }
  /**
   * @private
   * @method
   */
  openInfoWin() {
    this.infoWin.open(this.map, this.marker)
  }
  /**
   * @private
   * @method
   * @return {google.maps.LatLngLiteral|undefined} The destination location
   */
  getLatLng() {
    try {
      const coord = proj4('EPSG:3857', 'EPSG:4326', this.args.destination.coordinate)
      return {lat: coord[1], lng: coord[0]}
    } catch (ignore) {/* no destination specified */}
  }
  /**
   * @private
   * @method
   * @param {jQuery.Event} event Event object
   */
  zoom(event) {
    const z = this.map.getZoom()
    this.map.setZoom(z + ($(event.target).data('zoom-incr') * 1))
  }
  /**
   * @private
   * @method
   * @param {jQuery.Event} event Event object
   */
  mode(event) {
    this.args = this.args || {}
    this.modeBtn = event.target
    this.args.mode = $(this.modeBtn).data('mode')
    this.modeAria()
    this.directions(this.args)
  }
  /**
   * @private
   * @method
   */
  modeAria() {
    $('#mode button').removeClass('active').attr({
      'aria-selected': false,
      'aria-pressed': false
    })
    $(this.modeBtn).addClass('active').attr({
      'aria-selected': true,
      'aria-pressed': true
    })
  }
  /**
   * @private
   * @method
   * @param {jQuery.Event} event Event object
   */
  key(event) {
    if (event.keyCode === 13) {
      this.args.from = $('#fld-from input').val()
      this.directions(this.args)
    }
  }
  tripPlanHack() {
    this.args.accessible = true
    new TripPlanHack().directions(this.args)
  }
}

/**
 * @desc The default URL for loading Google APIs
 * @public
 * @const
 * @type {string}
 */
Directions.DEFAULT_GOOGLE_URL = 'https://maps.googleapis.com/maps/api/js?&sensor=false&libraries=visualization'

/**
 * @desc Object type for getting directions
 * @public
 * @typedef {Object}
 * @property {string=} from The origin location
 * @property {string} to The destination location
 * @property {google.maps.DirectionsTravelMode} mode The directions mode
 * @property {string} facility The name of the destination
 * @property {JQuery|Element|string=} returnFocus The DOM element that should receive focus when leaving the directions view
 */
Directions.Request

/**
 * @desc Object type for getting directions
 * @public
 * @typedef {Object}
 * @property {Object} response The Google response
 * @property {google.maps.DirectionsStatus} status The status of the response
 */
Directions.Response

/**
 * @desc The class has completed initialization
 * @event nyc.Directions#changed
 * @type {module:nyc/Directions~Directions.Response}
 */

/**
 * @desc Constructor options for {@link module:nyc/Directions~Directions}
 * @public
 * @typedef {Object}
 * @property {string} [url={@link module:nyc/Directions~Directions.DEFAULT_GOOGLE_URL}] The Google Maps URL to use
 * @property {Array<Object<strng, Object>>=} styles The Google Maps styles to use (see {@link https://developers.google.com/maps/documentation/javascript/style-reference})
 * @property {jQuery|string=} toggle Elements to hide from screen readers when directions are shown
 */
Directions.Options
/**
  * @private
  * @const
  * @type {string}
  */
Directions.HTML = '<div id="directions">' +
  '<button id="back-to-map" class="btn rad-all">' +
    'Back to finder' +
  '</button>' +
  '<div id="dir-tabs">' +
    '<div id="route-tab">' +
      '<div class="fld-lbl">From my location:</div>' +
      '<div id="fld-from"><input class="rad-all" placeholder="Enter an address..."></div>' +
      '<div class="fld-lbl">To <span id="fld-facility"></span>:</div>' +
      '<div id="fld-to"></div>' +
      '<table id="mode">' +
        '<tbody><tr>' +
          '<td>' +
            '<button id="transit" class="btn-sq rad-all active" data-mode="TRANSIT" aria-pressed="true" aria-selected="true" title="Get transit directions">' +
              '<span class="screen-reader-only">get transit directions</span>' +
            '</button>' +
          '</td>' +
          '<td>' +
            '<button id="bike" class="btn-sq rad-all" data-mode="BICYCLING" aria-pressed="false" aria-selected="false" title="Get bicycling directions">' +
              '<span class="screen-reader-only">get bicycling directions</span>' +
            '</button>' +
          '</td>' +
          '<td>' +
            '<button id="walk" class="btn-sq rad-all" data-mode="WALKING" aria-pressed="false" aria-selected="false" title="Get walking directions">' +
              '<span class="screen-reader-only">get walking directions</span>' +
            '</button>' +
          '</td>' +
          '<td>' +
            '<button id="car" class="btn-sq rad-all" data-mode="DRIVING" aria-pressed="false" aria-selected="false" title="Get driving directions">' +
              '<span class="screen-reader-only">get driving directions</span>' +
            '</button>' +
          '</td>' +
          '<td>' +
            '<span class="screen-reader-only">Get accessible transit directions from the MTA </span>' +
            '<button id="mta" class="btn-sq rad-all notranslate">TripPlanner' +
            '<svg xmlns="http://www.w3.org/2000/svg" width="656" height="656" viewBox="0 0 656 656"><g transform="translate(-263.86732,-69.7075)"><path d="M 833.556,367.574 C 825.803,359.619 814.97,355.419 803.9,356.025 l -133.981,7.458 73.733,-83.975 c 10.504,-11.962 13.505,-27.908 9.444,-42.157 -2.143,-9.764 -8.056,-18.648 -17.14,-24.324 -0.279,-0.199 -176.247,-102.423 -176.247,-102.423 -14.369,-8.347 -32.475,-6.508 -44.875,4.552 l -85.958,76.676 c -15.837,14.126 -17.224,38.416 -3.097,54.254 14.128,15.836 38.419,17.227 54.255,3.096 l 65.168,-58.131 53.874,31.285 -95.096,108.305 c -39.433,6.431 -74.913,24.602 -102.765,50.801 l 49.66,49.66 c 22.449,-20.412 52.256,-32.871 84.918,-32.871 69.667,0 126.346,56.68 126.346,126.348 0,32.662 -12.459,62.467 -32.869,84.916 l 49.657,49.66 c 33.08,-35.166 53.382,-82.484 53.382,-134.576 0,-31.035 -7.205,-60.384 -20.016,-86.482 l 51.861,-2.889 -12.616,154.75 c -1.725,21.152 14.027,39.695 35.18,41.422 1.059,0.086 2.116,0.127 3.163,0.127 19.806,0 36.621,-15.219 38.257,-35.306 l 16.193,-198.685 c 0.904,-11.071 -3.026,-21.989 -10.775,-29.942 z"/><path  d="m 762.384,202.965 c 35.523,0 64.317,-28.797 64.317,-64.322 0,-35.523 -28.794,-64.323 -64.317,-64.323 -35.527,0 -64.323,28.8 -64.323,64.323 0,35.525 28.795,64.322 64.323,64.322 z"/><path d="m 535.794,650.926 c -69.668,0 -126.348,-56.68 -126.348,-126.348 0,-26.256 8.056,-50.66 21.817,-70.887 l -50.196,-50.195 c -26.155,33.377 -41.791,75.393 -41.791,121.082 0,108.535 87.983,196.517 196.518,196.517 45.691,0 87.703,-15.636 121.079,-41.792 L 606.678,629.11 c -20.226,13.757 -44.63,21.816 -70.884,21.816 z"/></g></svg>' +
          '</td>' +
          '</tr></tbody>' +
        '</table>' +
        '<div class="no-trans">The origin and destination locations are so close that walking appears to be the best option.</div>' +
      '<div class="route"></div>' +
    '</div>' +
    '<div id="map-tab" aria-hidden="true">' +
      '<div class="map"></div>' +
      '<button class="btn-z-in btn-sq rad-all" data-zoom-incr="1" title="Zoom in">' +
        '<span class="screen-reader-only">Zoom in</span>' +
      '</button>' +
      '<button class="btn-z-out btn-sq rad-all" data-zoom-incr="-1" title="Zoom out">' +
        '<span class="screen-reader-only">Zoom out</span>' +
      '</button>' +
    '</div>' +
  '</div>' +
'</div>'

/**
 * @desc Default styles used for Google Maps API
 * @public
 * @static
 * @type {Array<Object<string, Object>>}
 */
Directions.DEFAULT_STYLES = [
  {elementType: 'geometry.fill', stylers: [{color: '#ececec'}]},
  {elementType: 'geometry.stroke', stylers: [{color: '#dcdcdc'}]},
  {elementType: 'labels.text.fill', stylers: [{color: '#585858'}]},
  {featureType: 'poi.park', elementType: 'geometry.fill', stylers: [{color: '#e8e8e8'}]},
  {featureType: 'water', elementType: 'geometry', stylers: [{color: '#d8d8d8'}]},
  {featureType: 'road', elementType: 'geometry.fill', stylers: [{color: '#ffffff'}]},
  {featureType: 'landscape.man_made', stylers: [{visibility: 'off'}]},
  {featureType: 'transit.line', stylers: [{visibility: 'off'}]},
  {featureType: 'administrative', stylers: [{visibility: 'off'}]},
  {featureType: 'poi', stylers: [{visibility: 'off'}]},
  {featureType: 'poi.government', stylers: [{visibility: 'on'}]},
  {featureType: 'poi.park', stylers: [{visibility: 'on'}]}
]

export default Directions