/**
* @module nyc/ol/MapMgr
*/
import $ from 'jquery'
import nyc from 'nyc'
import Decorate from 'nyc/ol/format/Decorate'
import FilterAndSort from 'nyc/ol/source/FilterAndSort'
import Collapsible from 'nyc/Collapsible'
import ListPager from 'nyc/ListPager'
import Basemap from 'nyc/ol/Basemap'
import MultiFeaturePopup from 'nyc/ol/MultiFeaturePopup'
import FeatureTip from 'nyc/ol/FeatureTip'
import Vector from 'ol/source/Vector'
import Layer from 'ol/layer/Vector'
import LocationMgr from 'nyc/ol/LocationMgr'
import MapLocator from 'nyc/MapLocator'
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'
import Style from 'ol/style/Style'
import Icon from 'ol/style/Icon'
import Circle from 'ol/style/Circle'
import Fill from 'ol/style/Fill'
import Stroke from 'ol/style/Stroke'
import Dialog from 'nyc/Dialog'
const proj4 = nyc.proj4
/**
* @desc Class that provides an nyc.ol.Basemap with controls and data from CSV
* @public
* @abstract
* @class
*/
class MapMgr {
/**
* @desc Create an instance of Basemap
* @public
* @constructor
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
*/
constructor(options) {
/**
* @private
* @member {module:nyc/Search~Search.FeatureSearchOptions}
*/
this.facilitySearch = options.facilitySearch
/**
* @private
* @member {module:nyc/ListPager~ListPager}
*/
this.pager = this.createPager(options)
/**
* @desc The map
* @public
* @member {ol.Map}
*/
this.map = new Basemap({
target: $(options.mapTarget).get(0),
altBasemap: options.altBasemap
})
/**
* @desc The view
* @public
* @member {ol.View}
*/
this.view = this.map.getView()
/**
* @desc The LocationMgr
* @public
* @member {module:nyc/ol/LocationMgr~LocationMgr}
*/
this.locationMgr = this.createLocationMgr(options)
/**
* @private
* @member {module:nyc/Locator~Locator.Result}
*/
this.location = {}
this.checkMouseWheel(options.mouseWheelZoom)
if (options.startAt) {
this.locationMgr.goTo(options.startAt)
}
/**
* @desc The vector layer for facilities
* @public
* @member {ol.layer.Vector}
*/
this.layer = null
/**
* @desc The data to display in the map layer
* @public
* @member {module:nyc/ol/source/FilterAndSort~FilterAndSort}
*/
this.source = this.createSource(options)
/**
* @private
* @member {ol.source.Vector}
*/
this.highlightSource = null
if (this.source) {
this.layer = this.createLayer(this.source, this.createStyle(options))
this.map.addLayer(this.layer)
this.source.autoLoad().then($.proxy(this.ready, this)).catch($.proxy(this.loadFailed, this))
this.createHighlightLayer(options)
} else {
this.ready()
}
/**
* @desc The popup
* @public
* @member {module:nyc/ol/MultiFeaturePopup~MultiFeaturePopup}
*/
this.popup = null
if (this.layer) {
this.popup = new MultiFeaturePopup({
map: this.map,
layers: [this.layer]
})
new FeatureTip({
map: this.map,
tips: [{
layer: this.layer,
label: MapMgr.tipFunction
}]
})
}
}
/**
* @desc Create the parent format for the source
* @public
* @abstract
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
* @returns {ol.format.Feature} The parent format
*/
createParentFormat(options) {
throw 'must be implemented'
}
/**
* @desc Create the feature decorations
* @public
* @abstract
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
* @returns {Array<Object<string, Object>>} The embellished decorations
*/
createDecorations(options) {
throw 'must be implemented'
}
/**
* @desc Create the leayer
* @public
* @abstract
* @param {module:nyc/ol/MapMgr~MapMgr.Options} source The data source for the layer
* @param {ol.style.Style} style The style for the layer
* @returns {ol.layer.Vector} The layer
*/
createLayer(source, style) {
const layer = new Layer({
source: source,
style: style
})
return layer
}
/**
* @desc Handles geocoded and geolocated events
* @access protected
* @method
* @param {module:nyc/Locator~Locator.Result} location Location
*/
located(location) {
this.location = location
if (this.pager) {
this.resetList()
this.pager.find('h2.info')
.append(' sorted by closest to ')
.append(location.name)
this.focusInfo()
}
}
/**
* @desc Focus on screen reader alert
* @access protected
* @method
*/
focusInfo() {
this.pager.find('h2.info')
.attr('aria-live', 'alert')
.attr('tabindex', 0)
.trigger('focus')
}
/**
* @desc Reset the facilities list
* @public
* @method
* @param {Object} event Event object
*/
resetList(event) {
const coordinate = this.location.coordinate
this.popup.hide()
if (this.pager) {
if (coordinate) {
this.pager.reset(this.source.sort(coordinate))
} else {
this.pager.reset(this.source.getFeatures())
}
}
}
/**
* @desc Handles features after they are loaded
* @access protected
* @method
* @param {Array<ol.Feature>} features The facility features
*/
ready(features) {
this.view.fit(Basemap.EXTENT, {
size: this.map.getSize(),
duration: 500
})
if (this.source && this.facilitySearch) {
const options = typeof this.facilitySearch === 'object' ? this.facilitySearch : {}
options.features = features
this.locationMgr.search.setFeatures(options)
}
if (this.pager) {
this.pager.reset(features)
}
nyc.ready($('body'))
}
/**
* @desc Centers and zooms to the provided feature
* @public
* @method
* @param {ol.Feature} feature OpenLayers feature
*/
zoomTo(feature) {
const popup = this.popup
popup.hide()
this.map.once('moveend', () => {
popup.showFeature(feature)
})
this.view.animate({
center: feature.getGeometry().getCoordinates(),
zoom: MapLocator.ZOOM_LEVEL
})
}
/**
* @desc Provides directions to the provided facility feature
* @public
* @method
* @param {ol.Feature} feature OpenLayers feature
*/
directionsTo(feature) {
const to = encodeURIComponent(feature.getFullAddress())
let from = this.getFromAddr()
from = from ? encodeURIComponent(from) : to
window.open(`https://www.google.com/maps/dir/${from}/${to}`)
}
/**
* @desc Convert current user location into usable form for Google directions API
* @protected
* @method
* @return {string|Array<number>} Location coordinates or name
*/
getFromAddr() {
const location = this.location
if (location.type === 'geolocated' || location.isFeature) {
const coordinates = proj4(
this.view.getProjection().getCode(),
'EPSG:4326',
location.coordinate
)
return `${coordinates[1]},${coordinates[0]}`
}
return location.name || ''
}
/**
* @desc Handles the event triggered when a rendered feature's collapsible details is expanded
* @access protected
* @method
* @param {Object} event Event object
*/
expandDetail(event) {
this.popup.pan()
}
/**
* @private
* @method
* @param {string} url The image URL
* @param {number} height The desired height of the rendered marker
* @param {ol.style.Style} style The style
*/
loadMarkerImage(url, height, style) {
const me = this
const image = $('<img>').one('load', (event) => {
const size = [$(image).width(), $(image).height()]
style.setImage(new Icon({
src: url,
scale: height / size[1],
imgSize: size
}))
$(image).remove()
me.layer.setStyle(me.layer.getStyle())
})
$('body').append(image.attr('src', url))
}
isOlStyle(style) {
return style && (
style instanceof Style ||
(style instanceof Array && style[0] instanceof Style) ||
typeof style === 'function'
)
}
isBuckets(style) {
return style && !this.isOlStyle(style) && style.styles instanceof Object
}
/**
* @private
* @method
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
* @returns {ol.style.StyleLike} The style
*/
createStyle(options) {
const me = this
const facilityStyle = options.facilityStyle
if (me.isOlStyle(facilityStyle)) {
return facilityStyle
} else if (me.isBuckets(facilityStyle)) {
return (feature, resolution) => {
const type = feature.get(facilityStyle.typeCol || 'TYPE')
return me.createFacilityStyle(facilityStyle.styles[type])
}
} else if (options.facilityMarkerUrl) {
const style = new Style()
this.loadMarkerImage(options.facilityMarkerUrl, 32, style)
return style
} else {
return me.createFacilityStyle(facilityStyle)
}
}
/**
* @private
* @method
* @param {module:nyc/ol/MapMgr~MapMgr.Style} style The style to create
* @returns {ol.style.Style} The style
*/
createFacilityStyle(style) {
if (typeof style === 'string') {
const olStyle = new Style()
this.loadMarkerImage(style, 32, olStyle)
return olStyle
}
const color = style || [0, 0, 255]
const rgb = color.join(',')
return new Style({
image: new Circle({
radius: 6,
fill: new Fill({
color: `rgba(${rgb},.5)`
}),
stroke: new Stroke({
color: `rgb(${rgb})`,
width: 2
})
})
})
}
/**
* @private
* @method
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
*/
createHighlightLayer(options) {
this.highlightSource = new Vector()
this.highlightLayer = new Layer({
source: this.highlightSource,
style: this.createHighlightStyle(options)
})
this.map.addLayer(this.highlightLayer)
}
/**
* @private
* @method
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
* @returns {ol.style.Style} The style
*/
createHighlightStyle(options) {
if (options.highlightStyle) {
return options.highlightStyle
}
return new Style({
image: new Circle({
radius: 20,
stroke: new Stroke({
color: 'yellow',
width: 10
})
})
})
}
/**
* @private
* @method
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
* @returns {module:nyc/ol/source/FilterAndSort~FilterAndSort} The sorce
*/
createSource(options) {
if (options.facilityUrl) {
const parentFormat = this.createParentFormat(options)
const decorations = this.createDecorations(options)
return new FilterAndSort({
url: options.facilityUrl,
format: new Decorate({
decorations: decorations,
parentFormat: parentFormat
})
}, options.metric)
}
}
/**
* @private
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
* @returns {module:nyc/ListPager~ListPager|undefined} The pager
*/
createPager(options) {
if (options.listTarget) {
return new ListPager({
target: options.listTarget,
itemType: options.facilityType
})
}
}
/**
* @private
* @method
* @param {module:nyc/ol/MapMgr~MapMgr.Options} options Constructor options
* @returns {module:nyc/ol/LocationMgr~LocationMgr} The LocationMgr instance
*/
createLocationMgr(options) {
const facilitySearch = options.facilitySearch
const locationMgr = new LocationMgr({
map: this.map,
searchTarget: options.searchTarget || undefined,
dialogTarget: options.dialogTarget || options.mapTarget,
url: options.geoclientUrl,
placeholder: facilitySearch ? facilitySearch.placeholder : undefined
})
if (options.searchTarget === false) {
locationMgr.search.getContainer().hide()
}
if (options.locationMarkerUrl) {
const layer = locationMgr.mapLocator.layer
const style = new Style()
layer.setStyle(style)
this.loadMarkerImage(options.locationMarkerUrl, 64, layer.getStyle())
}
locationMgr.on('geocoded', this.located, this)
locationMgr.on('geolocated', this.located, this)
return locationMgr
}
/**
* @protected
* @method
* @param {Error} err The Error
*/
loadFailed(err) {
console.error(err)
this.ready()
new Dialog({}).ok({message: 'Failed to load map data'})
}
/**
* @private
* @method
* @param {boolean} mouseWheelZoom Allow mouse wheel zoom
*/
checkMouseWheel(mouseWheelZoom) {
if (mouseWheelZoom !== true) {
let wheel
this.map.getInteractions().forEach(interaction => {
if (interaction instanceof MouseWheelZoom) {
wheel = interaction
}
})
this.map.removeInteraction(wheel)
}
}
/**
* @desc Highlight a single feature on the map
* @public
* @method
* @param {ol.Feature=} feature The feature
*/
highlight(feature) {
this.highlightSource.clear()
if (feature) {
this.highlightSource.addFeatures([feature])
}
}
}
/**
* @desc Default facility feature decorations
* @public
* @static
* @function
* @param {ol.Feature} feature The feature
* @returns {Object<string, string>} The tip
*/
MapMgr.tipFunction = (feature) => {
return {html: feature.getTip()}
}
/**
* @desc Default facility feature decorations
* @public
* @const
* @mixin
* @type {Object<string, fuction>}
*/
MapMgr.FEATURE_DECORATIONS = {
/**
* @desc Returns a facility feature rendered as a jQuery
* @public
* @method
* @return {jQuery} The rendered feature
*/
html() {
return $('<div class="facility"></div>')
.addClass(this.cssClass())
.addClass(this.moreCss())
.append(this.distanceHtml())
.append(this.nameHtml())
.append(this.distanceHtml(true))
.append(this.addressHtml())
.append(this.phoneButton())
.append(this.emailButton())
.append(this.websiteButton())
.append(this.mapButton())
.append(this.directionsButton())
.append(this.detailsCollapsible())
.data('feature', this)
.mouseover($.proxy(this.handleOver, this))
.mouseout($.proxy(this.handleOut, this))
},
moreCss() {
const phone = this.getPhone()
const website = this.getWebsite()
if (phone && !website) {
return 'no-website'
}
if (!phone && website) {
return 'no-phone'
}
},
/**
* @desc Returns the feature tip value
* @public
* @method
* @returns {string} Tip text
*/
getTip() {
return $('<span></span>')
.html(this.getName())
.addClass('notranslate')
},
/**
* @desc Returns the name of a facility feature
* @public
* @method
*/
getName() {
throw 'A getName decoration must be provided'
},
/**
* @desc Returns a CSS class for the rendered facility feature
* @public
* @method
*/
cssClass() {},
/**
* @desc Returns the address line 1 of a facility feature
* @public
* @method
*/
getAddress1() {
throw 'A getAddress1 decoration must be provided to use default html method and directions'
},
/**
* @desc Returns the address line 2 of a facility feature
* @public
* @method
* @return {string} The address line 2
*/
getAddress2() {
return ''
},
/**
* @desc Returns the city, state zip line of a facility feature
* @public
* @method
*/
getCityStateZip() {
throw 'A getCityStateZip decoration must be provided to use default html method and directions'
},
/**
* @desc Returns full address for use with Google directions API
* @public
* @method
* @return {string} The full address
*/
getFullAddress() {
return `${this.getAddress1()}\n${this.getAddress2()},\n${this.getCityStateZip()}`
},
/**
* @desc Returns the phone number for a facility feature
* @public
* @method
*/
getPhone() {},
/**
* @desc Returns the email for a facility feature
* @public
* @method
*/
getEmail() {},
/**
* @desc Returns the website URL for a facility feature
* @public
* @method
*/
getWebsite() {},
/**
* @desc Returns additional details for the facility feature
* @public
* @method
*/
detailsHtml() {},
/**
* @desc Returns the name of a facility feature as jQuery
* @public
* @method
* @return {jQuery} The name of a facility feature as jQuery
*/
nameHtml() {
return $('<h3 class="name notranslate" tabindex="0"></h3>').html(this.getName())
},
/**
* @desc Returns the full address of a facility feature as jQuery
* @public
* @method
* @return {jQuery} The full address of a facility feature as jQuery
*/
addressHtml() {
const html = $('<div class="addr notranslate"></div>')
.append(`<div class="ln1">${this.getAddress1()}</div>`)
if (this.getAddress2()) {
html.append(`<div class="ln2">${this.getAddress2()}</div>`)
}
return html.append(`<div class="ln3">${this.getCityStateZip()}</div>`)
},
/**
* @desc Returns a button as jQuery that when clicked will zoom to the facility
* @public
* @method
* @return {jQuery} The map button as jQuery
*/
mapButton() {
return $('<button class="btn btn-ico rad-all map"></button>')
.append('<span class="msg-map" aria-hidden="true">Map</span>')
.append('<span class="screen-reader-only msg-sr-map">Locate this facility on the map</span>')
.data('feature', this)
.click(this.handleButton)
},
/**
* @desc Returns a button as jQuery that when clicked will provide directions to the facility
* @public
* @method
* @return {jQuery} The directions button as jQuery
*/
directionsButton() {
return $('<button class="btn btn-ico rad-all dir msg-dir">Directions</button>')
.data('feature', this)
.click(this.handleButton)
},
/**
* @desc Returns a button as jQuery that when clicked will call the provided phone number
* @public
* @method
* @return {jQuery} The phone button as jQuery
*/
phoneButton() {
const phone = this.getPhone()
if (phone) {
return $(`<a class="btn btn-ico rad-all phone notranslate" role="button">${phone}</a>`)
.attr('href', `tel:${phone}`)
}
},
/**
* @desc Returns a button as jQuery that when clicked will open email editor for the provided email
* @public
* @method
* @return {jQuery} The email button as jQuery
*/
emailButton() {
const email = this.getEmail()
if (email) {
return $('<a class="btn btn-ico rad-all email msg-email" role="button">Email</a>')
.attr('href', `mailto:${email}`)
}
},
/**
* @desc Returns a button as jQuery that when clicked will open the facility web site
* @public
* @method
* @return {jQuery} The website button as jQuery
*/
websiteButton() {
const url = this.getWebsite()
if (url) {
return $('<a class="btn btn-ico rad-all web msg-web" target="blank" role="button">Website</a>')
.attr('href', url)
}
},
/**
* @desc Returns the distance from the user location to the facility as jQuery
* @public
* @method
* @param {boolean} screenReaderOnly Return distance for screen readers
* @return {jQuery} The distance from the user location to the facility as jQuery
*/
distanceHtml(screenReaderOnly) {
if (this.getDistance) {
const html = $('<div class="dist"></div>')
const distance = this.getDistance()
if (screenReaderOnly) {
html.addClass('screen-reader-only')
if (distance.units === 'ft') {
return html.html(`${(distance.distance / 5280).toFixed(2)} miles`)
}
return html.html(`${(distance.distance / 1000).toFixed(2)} kilometers`)
}
html.attr('aria-hidden', true)
if (distance.units === 'ft') {
return html.html(`• ${(distance.distance / 5280).toFixed(2)} mi •`)
}
return html.html(`• ${(distance.distance / 1000).toFixed(2)} km •`)
}
},
/**
* @desc Returns collapsible details for the facility features as jQuery
* @public
* @method
* @return {jQuery} The collapsible details as jQuery
*/
detailsCollapsible() {
const details = this.detailsHtml()
if (details) {
const collapsible = new Collapsible({
target: $('<div class="dtl"></div>'),
title: this.detailButtonText || '<span class="msg-dtl">Details</span>',
content: details,
collapsed: true
})
collapsible.on('change', this.app.expandDetail, this.app)
return collapsible.getContainer()
}
},
/**
* @desc Handles map and direction button click
* @public
* @static
* @function
* @param {jQuery.Event} event Event object
*/
handleButton(event) {
const target = $(event.currentTarget)
const feature = target.data('feature')
if (target.hasClass('map')) {
feature.app.zoomTo(feature)
} else {
feature.app.directionsTo(feature, target)
}
},
/**
* @desc Handles mouse over a feature's HTML
* @public
* @static
* @function
* @param {jQuery.Event} event Event object
*/
handleOver(event) {
const target = $(event.currentTarget)
const feature = target.data('feature')
feature.app.highlight(target.data('feature'))
},
/**
* @desc Handles mouse out a feature's HTML
* @public
* @static
* @function
* @param {jQuery.Event} event Event object
*/
handleOut(event) {
const target = $(event.currentTarget)
const feature = target.data('feature')
feature.app.highlight()
}
}
/**
* @desc Constructor options for {@link module:nyc/ol/MapMgr~MapMgr}
* @public
* @typedef {Object}
* @property {string} facilityUrl The URL for the facility features data
* @property {string} geoclientUrl The URL for the Geoclient geocoder with approriate keys
* @property {jQuery|Element|string=} mapTarget The target element for the map
* @property {jQuery|Element|string|boolean=} searchTarget The target element for search input
* @property {jQuery|Element|string=} listTarget The target element for facility list
* @property {string} [facilityType=Facilities] Title for the facilites list
* @property {module:nyc/ol/MapMgr~MapMgr.Style|module:nyc/ol/MapMgr~MapMgr.StyleByType|ol.style.Style=} facilityStyle The styling for the facilities layer
* @property {ol.style.Style=} highlightStyle The styling for highlighting facilities layer
* @property {module:nyc/Search~Search.FeatureSearchOptions|boolean} [facilitySearch=true] Search options for feature searches or true to use default search options
* @property {boolean} [mouseWheelZoom=false] Allow mouse wheel map zooming
* @property {string=} startAt A starting location
* @property {boolean} [metric=false] Use metric distance units
*/
MapMgr.Options
/**
* @desc Constructor options for {@link module:nyc/ol/MapMgr~MapMgr}
* @public
* @typedef {string|Array<number>} A URL to an image for use as a point marker or an RGB color for use in a point marker
*/
MapMgr.Style
/**
* @desc Constructor options for {@link module:nyc/ol/MapMgr~MapMgr}
* @public
* @typedef {Object}
* @property {string} [typeCol=TYPE] The CSV column containig the type values
* @property {Object<string, module:nyc/ol/MapMgr~MapMgr.Style>} styles Styles to apply by type
*/
MapMgr.StyleByType
export default MapMgr