Source: ol/FinderApp.js

/**
 * @module nyc/ol/FinderApp
 */

import $ from 'jquery'
import nyc from 'nyc'
import MapMgr from 'nyc/ol/MapMgr'
import Dialog from 'nyc/Dialog'
import Share from 'nyc/Share'
import Tabs from 'nyc/Tabs'
import Directions from 'nyc/Directions'
import Translate from 'nyc/lang/Translate'
import Goog from 'nyc/lang/Goog'
import Filters from 'nyc/ol/Filters'
import FilterAndSort from 'nyc/ol/source/FilterAndSort'

/**
 * @desc A class that provides a template for creating basic finder apps
 * @public
 * @class
 */
class FinderApp extends MapMgr {
  /**
   * @desc Create an instance of FinderApp
   * @public
   * @constructor
   * @param {module:nyc/ol/FinderApp~FinderApp.Options} options Constructor options
   */
  constructor(options) {
    $('body').append(FinderApp.HTML).addClass('fnd')
    options.listTarget = '#facilities div'
    options.mapTarget = '#map'
    options.dialogTarget = 'body'
    options.mouseWheelZoom = options.mouseWheelZoom === undefined ? true : options.mouseWheelZoom
    super(options)
    global.finderApp = this
    this.pager.find('h2.info').addClass('screen-reader-only')
    const title = $('<span></span>').html(options.title)
    nyc.noSpaceBarScroll()
    $('#banner').html(title)
    $('#home').attr('title', title.text())
    $('.fnd #home').on('click', () => FinderApp.reload())
    /**
     * @desc The filters for filtering the facilities
     * @public
     * @member {module:nyc/ol/Filters~Filters}
     */
    this.filters = this.createFilters(options.filterChoiceOptions)
    /**
     * @desc The tabs containing the map, facilities and filters
     * @public
     * @member {module:nyc/Tabs~Tabs}
     */
    this.tabs = this.createTabs(options)
    $('#map').attr('tabindex', -1)
    this.adjustTabs()
    this.showSplash(options.splashOptions, options.title)
    new Share({target: '#map'})
    /**
     * @private
     * @member {module:nyc/lang/Goog~Goog}
     */
    this.translate = this.translateBtn(options)
    /**
     * @private
     * @member {module:nyc/Directions~Directions}
     */
    this.directions = null
    /**
     * @private
     * @member {string}
     */
    this.directionsUrl = options.directionsUrl
    /**
     * @private
     * @member {google.maps.TravelMode}
     */
    this.defaultDirectionsMode = options.defaultDirectionsMode
    /**
     * @private
     * @member {module:nyc/Dialog~Dialog}
     */
    this.mobileDia = new Dialog({css: 'shw-lst'})
    /**
     * @private
     * @member {module:nyc/Dialog~Dialog}
     */
    this.screenReaderDialog = new Dialog({css: 'screen-reader-info'})
    $('#screen-reader-info').on('click', $.proxy(this.screenReaderInfo, this))
    $('.shw-lst .btn-yes span').html(`View nearby ${$('#tab-btn-1').html()} in an accessible list`)
    this.setRefresh(options.refresh)
    $(window).on('load resize', $.proxy(this.adjustBanner, this))
    this.translate.on('change', this.adjustBanner, this)
  }
  /**
   * @desc Scale font size responsively
   * @public
   * @method
   */
  adjustBanner() {
    setTimeout(() => {
      const bannerText = $('h1#banner>span, h1#banner>span *')
      const banner = $('h1#banner')
      const screenReaderBtn = $('a#screen-reader-info')
      const backToMapBtn = $('button#back-to-map')
      const logoWidth = parseInt(banner.css('padding-left') || 0)
      let offsetButton = backToMapBtn

      if (!document.contains($('#directions')[0]) || $('#directions').css('display') === 'none') {
        offsetButton = screenReaderBtn
      }

      bannerText.css('font-size', '24px')
      banner.css('padding-right', '0')
      // calculate container width that text lies in (exclude NYC logo + screenReader/backToMap button)

      if (bannerText.width() >= (banner.innerWidth() - logoWidth - offsetButton.innerWidth())) {
        bannerText.css('font-size', '16px')
        banner.css('padding-right', `${offsetButton.innerWidth()}px`)
      }

    }, 1200)
  }
  /**
   * @private
   * @method
   * @param {module:nyc/ol/FinderApp~FinderApp.RefreshOptions} refresh Refresh options
   */
  setRefresh(refresh) {
    if (refresh) {
      const me = this
      setInterval(() => {
        const url = me.source.getUrl().split('?')[0]
        const source = new FilterAndSort({
          url: `${url}?${nyc.cacheBust(.33)}`,
          format: me.source.getFormat()
        })
        source.autoLoad().then(() => {
          me.source = source
          me.filters.source = source
          me.filters.filter()
          me.layer.setSource(source)
          me.resetList()
          if (refresh.callback) {
            refresh.callback()
          }
        })
      }, refresh.minutes * 60 * 1000)
    }
  }
  /**
   * @desc Create the translate button
   * @public
   * @method
   * @param {module:nyc/ol/FinderApp~FinderApp.Options} options Constructor options
   * @return {nyc.lang.Goog~Goog} Goog instance
   */
  translateBtn(options) {
    const languages = options.languages || Translate.DEFAULT_LANGUAGES
    if (options.splashOptions) {
      const translate = new Goog({
        target: '.fnd .splash .dia-btns',
        languages
      })
      $('.splash .dia-btns').append($('.splash .btn-no'))
      return translate
    }
    return new Goog({
      target: 'body',
      languages,
      button: true
    })
  }
  /**
   * @desc Reset the facilities list
   * @public
   * @method
   * @param {Object} event Event object
   */
  resetList(event) {
    super.resetList(event)
    if (event instanceof Filters) {
      $('#tabs .btns .btn-2').addClass('filtered')
    }
  }
  /**
   * @public
   * @override
   * @method
   * @param {module:nyc/ol/FinderApp~FinderApp.Options} options Constructor options
   * @return {ol.format.Feature} The parent format
   */
  createParentFormat(options) {
    return options.facilityFormat.parentFormat || options.facilityFormat
  }
  /**
   * @public
   * @override
   * @method
   * @param {module:nyc/ol/FinderApp~FinderApp.Options} options Constructor options
   * @param {ol.format.Feature} format The OpenLayers feature format
   * @return {Array<Object<string, fuction>>} Decorations
   */
  createDecorations(options) {
    const format = options.facilityFormat
    let decorations = [MapMgr.FEATURE_DECORATIONS, {app: this}]
    if (format.parentFormat && format.parentFormat.decorations) {
      decorations = decorations.concat(format.parentFormat.decorations)
    }
    if (format.decorations) {
      decorations = decorations.concat(format.decorations)
    }
    if (options.decorations) {
      decorations = decorations.concat(options.decorations)
    }
    return decorations
  }
  /**
   * @desc Centers and zooms to the provided feature
   * @public
   * @method
   * @param {ol.Feature} feature OpenLayers feature
   */
  zoomTo(feature) {
    if ($('#tabs .btns h2:first-of-type').css('display') !== 'none') {
      this.tabs.open('#map')
    }
    super.zoomTo(feature)
  }
  /**
   * @desc Handles geocoded and geolocated events
   * @access protected
   * @method
   * @param {module:nyc/Locator~Locator.Result} location Location
   */
  located(location) {
    super.located(location)
    this.focusFacilities()
  }
  /**
   * @desc Provides directions to the provided facility feature
   * @public
   * @override
   * @method
   * @param {ol.Feature} feature OpenLayers feature
   * @param {JQuery} returnFocus The DOM element that should receive focus when leaving the directions view
   */
  directionsTo(feature, returnFocus) {
    if (!this.directions) {
      this.directions = new Directions({
        url: this.directionsUrl,
        toggle: '#tabs',
        mode: this.defaultDirectionsMode
      })
      $('button#back-to-map').on('click', $.proxy(this.adjustBanner, this))
    }
    const to = feature.getFullAddress()
    const name = feature.getName()
    const from = this.getFromAddr()
    this.directions.directions({
      from: from,
      to: to,
      facility: name,
      origin: this.location,
      destination: {
        name: feature.getName(),
        coordinate: feature.getGeometry().getCoordinates()
      },
      returnFocus: returnFocus
    })
    this.adjustBanner()
  }
  /**
   * @desc Creates the filters for the facility features
   * @access protected
   * @method
   * @param {Array<module:nyc/ol/Filters~Filters.ChoiceOptions>=} choiceOptions Choice options
   * @return {module:nyc/ol/Filters~Filters} Filters
   */
  createFilters(choiceOptions) {
    if (choiceOptions) {
      const me = this
      $('#filters').empty()
      const apply = $('<button class="apply">Apply</button>')
      const filters = new Filters({
        target: '#filters',
        source: me.source,
        choiceOptions: choiceOptions
      })
      filters.on('change', me.resetList, me)
      $('#filters').append(apply)
      apply.on('click tap keypress', () => {
        me.focusFacilities(true)
      })
      return filters
    }
  }
  /**
   * @desc Creates the tabs for the map, facilities and filters
   * @access protected
   * @method
   * @param {module:nyc/ol/FinderApp~FinderApp.Options} options Options
   * @return {module:nyc/Tabs~Tabs} Tabs
   */
  createTabs(options) {
    const pages = [
      {tab: '#map', title: '<span class="msg-map">Map</span>'},
      {tab: '#facilities', title: options.facilityTabTitle || '<span class="msg-facilities">Facilities</span>'}
    ]
    if (options.splashOptions) {
      $('#tabs').attr('aria-hidden', true)
    } else {
      $('#screen-reader-info').attr('role', 'alert')
    }
    if (this.filters) {
      pages.push({tab: '#filters', title: options.filterTabTitle || '<span class="msg-filters">Filters</span>'})
    }
    const tabs = new Tabs({target: '#tabs', tabs: pages})
    tabs.on('change', this.tabChange, this)
    $(window).resize($.proxy(this.adjustTabs, this))
    return tabs
  }
  /**
   * @private
   * @method
   * @returns {boolean} The display state
   */
  isMobile() {
    return $('#tabs .btns>h2:first-of-type').css('display') !== 'none'
  }
  /**
   * @private
   * @method
   * @param {string} id The tab id
   * @returns {boolean} The display state
   */
  isTab(id) {
    return this.tabs.active.get(0).id === id
  }
  /**
   * @private
   * @method
   * @returns {module:nyc/Dialog~Dialog.ShowOptions} Dialog options
   */
  mobileDiaOpts() {
    const location = this.location
    const locationName = location.name
    const feature = this.source.sort(location.coordinate)[0]
    const options = {
      buttonText: [
        `<span class="msg-vw-list">View ${$('#tab-btn-1').text()} list</span>`,
        '<span class="msg-vw-map">View the map</span>'
      ]
    }
    if (feature.getName() !== locationName) {
      options.message = `<strong>${feature.getName()}</strong><br>` +
        '<span class="msg-closest">is closest to your location</span><br>'
      if (location.type != 'geolocated') {
        options.message += `<strong>${locationName}</strong>`
      }
    } else {
      options.message = `<strong>${locationName}</strong>`
    }
    return options
  }
  /**
   * @private
   * @method
   * @param {boolean} applyBtn The filter apply button was pressed by a screen reader user
   */
  focusFacilities(applyBtn) {
    const tabs = this.tabs
    const dia = this.mobileDia
    if (!applyBtn && this.isMobile() && this.isTab('map')) {
      if ($('.shw-lst').css('display') === 'none') {
        const options = this.mobileDiaOpts()
        setTimeout(() => {
          dia.yesNo(options).then(showFacilities => {
            if (showFacilities) {
              tabs.open('#facilities')
              this.focusInfo()
            }
          })
        }, 250)
      }
    } else {
      tabs.open('#facilities')
      this.focusInfo()
    }
  }
  /**
   * @private
   * @method
   * @param {module:nyc/Dialog~Dialog.Options} options Dialog options
   * @param {string} title App title
   */
  showSplash(options, title) {
    const input = this.locationMgr.search.input
    if (options) {
      const msg = $('<div></div>').append(options.message)
      if (msg.find('h1').length === 0) {
        msg.prepend(`<h1 class="screen-reader-only">${title}</h1>`)
        options.message = msg.html()
      }
      options.buttonText = options.buttonText ||
        [
          '<span class="msg-sr-info">Screen reader instructions</span>',
          '<span class="msg-continue">Continue</span>'
        ]
      new Dialog({css: 'splash'}).yesNo(options).then(info => {
        if (info) {
          this.screenReaderInfo()
        } else {
          $('#tabs').attr('aria-hidden', false)
          this.translate.showButton('#map')
          input.trigger('focus')
        }
      })
    } else {
      input.trigger('focus')
    }
  }
  screenReaderInfo() {
    const input = this.locationMgr.search.input
    this.screenReaderDialog.find('.btn-ok').one('focus', () => {
      $('.screen-reader-info h1').trigger('focus')
    })
    this.screenReaderDialog.ok({
      message: FinderApp.SCREEN_READER_INFO,
      buttonText: ['Return to the map']
    }).then(() => {
      $('#tabs').attr('aria-hidden', false)
      this.translate.showButton('#map')
      input.trigger('focus')
    })
    $('.screen-reader-info .dia').get(0).scrollTop = 0
  }
  /**
   * @private
   * @method
   */
  adjustTabs() {
    /*
     * when input gets focus screen resizes on android
     * causing input to lose focus when tabs are adjusted
     * so we don't adjust tabs when input has focus
     */
    if ($('#directions').css('display') !== 'block' && !nyc.activeElement().isTextInput) {
      if (this.isMobile()) {
        if ($('#facilities').css('display') === 'block') {
          this.tabs.open('#facilities')
        } else {
          this.tabs.open('#map')
        }
      }
      this.moveSearch(this.tabs)
    }
    if (!this.isMobile() && !nyc.activeElement().isTextInput) {
      this.tabs.open('#facilities')
    }
    this.map.setSize([$('#map').width(), $('#map').height()])
  }
  /**
   * @desc Handles the tab change event
   * @access protected
   * @method
   * @param {module:nyc/Tabs~Tabs} tabs Tabs
   */
  tabChange(tabs) {
    if (this.isMobile()) {
      this.moveSearch(this.tabs)
    } else {
      $('#map').attr('aria-hidden', false)
    }
    this.map.setSize([$('#map').width(), $('#map').height()])
  }
  /**
   * @private
   * @method
   * @param {module:nyc/Tabs~Tabs} tabs Tabs
   */
  moveSearch(tabs) {
    const map = this.map
    const container = tabs.getContainer()
    const search = $('.srch-ctl')
    if (this.isTab('facilities') && this.isMobile()) {
      container.prepend(search.addClass('lst-srch'))
      container.prepend(tabs.find('.btns').get(0))
    } else {
      $(map.getTargetElement()).find('.ol-overlaycontainer-stopevent').append($('.srch-ctl').removeClass('lst-srch'))
    }
  }

}

/**
 * @desc Reload the page
 * @static
 * @public
 * @method
 */
FinderApp.reload = () => {
  document.location.reload()
}

/**
 * @desc Constructor options for {@link module:nyc/ol/FinderApp~FinderApp}
 * @public
 * @typedef {Object}
 * @property {string} title The app title
 * @property {module:nyc/Dialog~Dialog.Options} splashOptions Content to display as a splash page
 * @property {string} [facilityTabTitle=Facilities] Title for the facilites list
 * @property {string} facilityUrl The URL for the facility features data
 * @property {ol.format.Feature=} facilityFormat The format of the facilities data
 * @property {ol.style.Style} facilityStyle The styling for the facilities layer
 * @property {module:nyc/Search~Search.FeatureSearchOptions|boolean} [facilitySearch=false] Search options for feature searches or true to use default search options
 * @property {string} [filterTabTitle=Filters] Title for the filters tab
 * @property {Array<module:nyc/ol/Filters~Filters.ChoiceOptions>=} filterChoiceOptions Filter definitions
 * @property {string} geoclientUrl The URL for the Geoclient geocoder with approriate keys
 * @property {string} directionsUrl The URL for the Google directions API with approriate keys
 * @property {google.maps.TravelMode} defaultDirectionsMode The mode for Google directions
 * @property {boolean} [mouseWheelZoom=true] Allow mouse wheel map zooming
 * @property {module:nyc/ol/FinderApp~FinderApp.RefreshOptions=} refresh Options for periodically refreshing facility data
 */
FinderApp.Options

/**
 * @desc Constructor options for {@link module:nyc/ol/FinderApp~FinderApp}
 * @public
 * @typedef {Object}
 * @property {number} minutes Number of minutes for refresh interval
 * @property {function} callback Callback function for refresh
 */
FinderApp.RefreshOptions

/**
 * @private
 * @const
 * @type {string}
 */
FinderApp.HTML = `<h1 id="banner" role="banner"></h1>
<a id="screen-reader-info" class="btn-sq" href="#">
  <span class="screen-reader-only">screen reader instructions</span>
</a>
<a id="home" role="button" href="#">
  <span class="screen-reader-only">Reload page</span>
  <svg xmlns="http://www.w3.org/2000/svg" width="152" height="52">
    <g transform="translate(1.5,0)">
      <polygon points="15.5,1.2 3.1,1.2 0,4.3 0,47.7 3.1,50.8 15.5,50.8 18.6,47.7 18.6,35.3 34.1,50.8 46.6,50.8 49.7,47.7 49.7,4.3 46.6,1.2 34.1,1.2 31,4.3 31,16.7 "/>
      <polygon points="83.8,47.7 83.8,38.4 99.3,22.9 99.3,10.5 99.3,4.3 96.2,1.2 83.8,1.2 80.7,4.3 80.7,10.5 74.5,16.7 68.3,10.5 68.3,4.3 65.2,1.2 52.8,1.2 49.7,4.3 49.7,22.9 65.2,38.4 65.2,47.7 68.3,50.8 80.7,50.8 "/>
      <polygon points="145.9,29.1 130.4,29.1 130.4,32.2 118,32.2 118,19.8 130.4,19.8 130.4,22.9 145.9,22.9 149,19.8 149,10.5 139.7,1.2 108.6,1.2 99.3,10.5 99.3,41.5 108.6,50.8 139.7,50.8 149,41.5 149,32.2 "/>
    </g>
  </svg>
</a>
<div id="map"></div>
<div id="tabs"></div>
<div id="facilities"><div role="region"></div></div>
<div id="filters"></div>`

FinderApp.SCREEN_READER_INFO = `<h1 tabindex="0" aria-live="polite">Screen reader Instructions for NYC Finder Apps</h1>
<h2>Getting started</h2>
<p>
  This finder app uses the NYC DoITT nyc-lib javascript library which templates mapping 
  applications and provides accessibility functionality. Below are general screen reader 
  instructions on how to use all of the finder apps that use this template. 
  When the application first loads, there is a dialogue box with an introduction to each 
  specific finder app. Users can navigate to the continue or view map link and press 
  enter to proceed to the finder app. There is a "Reload" button that is always near 
  the top of the page that takes users back to this initial dialogue.    
</p>
<h2>Search for an address</h2>
<p>
  Users can enter an address to get a list of nearby locations. When the finder app loads, 
  screen reader and keyboard focus is dropped into a search field. Input an address then 
  press enter to submit. 
</p>
<p>
  If the address was entered correctly, the list of locations will 
  update, screen readers will announce the number of results and shift focus to the 
  beginning of the search results.
</p>
<p>
  If there are address suggestions, focus will shift to the first suggestion. Press tab 
  to cycle through suggestions and enter to select an address. Screen readers will announce
  the number of results and focus will shift to the beginning of the results area.
</p>
<h2>Navigating results</h2>
<p>
  Screen reader users can navigate by headings to browse through each result. For JAWS and 
  NVDA, press the "H" key. For Voiceover users, press Command, Option, Control and "H" or 
  just "H" with Quicknav and single letter navigation turned on. 
</p>
<p>
  Screen reader users can also use regions to navigate to different areas such as the banner,
  search form, and list of results. Jaws users can press the "R" key. NVDA users can press 
  the "D" key. Voiceover users can pull up a list of regions if it is included in their web
  rotor settings.    
</p>
<h2>Filtering results</h2>
<p>
  Users can filter results to get more specific results. Screen reader users can navigate by 
  headings to get to the "Filters" tab. It is towards the top of the page. JAWS and NVDA 
  users can press enter on the "Filters" tab in order to navigate to the filters page. 
  Voiceover users can press control, option and space bar.    
</p>
<p>
  On the filters page are checkboxes with different filtering options. JAWS and NVDA users 
  can press "X" to navigate through the various filter options and space bar to select 
  each one. Voiceover users can use control, option and the arrow keys to navigate then 
  control, option and space bar to select a filter.    
</p>
<p>
  Once a user has selected the filters they would like to apply, they can navigate to the 
  apply button. JAWS and NVDA users can press enter or spacebar to activate the "Apply" 
  button. Voiceover users can press control, option and space bar. The finder app will then 
  return to an updated list of results where screen reader users can navigate by heading 
  from one entry to another.    
</p>
<h2>Translate and share</h2>
<p>
  Below the search field are options to translate or share this page. JAWS and NVDA users 
  can use the tab key to navigate to them. Screen reader users can also navigate to them 
  using regions. JAWS users can press the “R” key to navigate to the translate or share 
  regions. NVDA users can use the “D” key to navigate to those regions. Voiceover users can 
  pull up a list of regions if it is included in their web rotor settings.    
</p>
<p>
  To translate, navigate to the translate combobox. JAWS and NVDA users can press enter to 
  open the combobox and the arrow keys to navigate through the options. Voiceover users 
  can open the combobox by pressing control, option and space bar then the arrow keys to 
  navigate through the options. All users can then press enter or return to make a selection.    
</p>
<p>
  To share this page, navigate to the share button or region. JAWS and NVDA users can press 
  enter on the “Share” toggle button to expand the share options. Voiceover users can press 
  control, option and space bar. All users can then use tab or the arrow keys to navigate 
  through the different share options and press enter or return to make a selection.     
</p>
<h2>Travel directions</h2>
<p>
  When browsing through each result, users can press enter on the "Directions" button in 
  order to get travel directions.     
</p>
<p>
  The screen that follows has Google maps directions for transit, walking, driving and ride 
  services. There is also an option for MTA Trip Planner which can give wheelchair accessible 
  directions. Please note that if a user chooses MTA Trip Planner, they will be taken to an 
  external website.    
</p>
<h2>Contact support</h2>
<p>To submit feedback about your experience or request assistance please <a href="https://www1.nyc.gov/nyc-resources/contact-the-citys-digital-accessibility-coordinator.page">contact the 
  Digital Accessibility Coordinator</a> at the Mayor's Office for People with Disabilities.
</p>`

export default FinderApp