import bboxTurf from '@turf/bbox'
import { lineString } from '@turf/helpers'
import centroid from '@turf/centroid'
import olMap from 'ol/Map'
import { fromLonLat } from 'ol/proj'
import GeoJSON from 'ol/format/GeoJSON'
import olLayerVector from 'ol/layer/Vector'
import olVectorTile from 'ol/layer/VectorTile'
import olSourceCluster from 'ol/source/Cluster'
import olFeature from 'ol/Feature'
import olGeomPoint from 'ol/geom/Point'
import debounce from 'lodash.debounce'
import ugh from 'ugh'
/**
* Creates a new feature with point geometry at the location of the pixel and attributes describing the pixel's value
* @function
* @category Popup
* @since 1.17.0
* @param {ol.Layer} layer - The openlayers layer
* @param {Object} event - An object with a `map` and `pixel` property
* @returns {ol.Feature} An openlayers feature with point geometry at the location of the pixel and attributes describing the pixel's value
*/
export const getPixelValue = (layer, event) => {
const { map, pixel } = event
const clickCoordinate = map.getCoordinateFromPixel(pixel)
let renderedLayer = layer
if (layer.isGeoserverLayer) renderedLayer = layer.getWMSLayer()
const renderContext = renderedLayer?.getRenderer()?.context
if (!renderContext) return null
const pixelImageData = renderContext.getImageData(pixel[0], pixel[1], 1, 1)
const [red, green, blue, alpha] = pixelImageData.data
return new olFeature({
geometry: new olGeomPoint(clickCoordinate),
title: layer.getProperties().title || 'Raster Pixel',
red,
green,
blue,
alpha
})
}
/**
* Bind multiple move listeners with the same callback
* @function
* @category Popup
* @since 0.2.0
* @param {ol.Map} map - The openlayers map to which the events are bound
* @param {Function} callback - The callback invoked when a `change:size`, `change:resolution` or a `change:center` event was fired
* @returns {ol.EventsKey[]} Array of openlayers event keys for unsetting listener events (use with removeMovementListener)
*/
export const addMovementListener = (map, callback) => {
if (typeof callback !== 'function') return ugh.error('\'addMovementListener\' requires a valid openlayers map & callback function') // eslint-disable-line
// If performance becomes an issue with catalog layers & far zoom level, these debounce levels can be adjusted
const slowDebounce = debounce(callback, 0)
const fastDebounce = debounce(callback, 0)
const keys = [
map.on('change:size', slowDebounce),
map.getView().on('change:resolution', slowDebounce),
map.getView().on('change:center', fastDebounce)
]
return keys
}
/**
* Remove list of event keys
* @function
* @category Popup
* @since 0.2.0
* @param {ol.Map} map - The openlayers map to which the events are bound
* @param {Array} keys - remove the listeners via` an array of event keys
*/
export const removeMovementListener = (keys = []) => {
keys.forEach(({ target, type, listener }) => target.un(type, listener))
}
const setParentLayer = ({ features, layer }) => {
return new Promise(resolve => {
const parentLayer = layer.get('_ol_kit_parent')
const parent = parentLayer || layer
features.forEach((feature, i) => {
// true makes this performant with a silent trigger: https://openlayers.org/en/latest/apidoc/module-ol_Feature.html#set
feature.set('_ol_kit_parent', parent, true)
return feature
})
resolve({ features, layer })
})
}
const wfsSelector = (layer, event, opts) => {
let features = []
const source = layer.getSource()
const { map, pixel } = event
const clickCoordinate = map.getCoordinateFromPixel(pixel)
// check for featuresAtPixel to account for hitTolerance
const featuresAtPixel = map.getFeaturesAtPixel(pixel, {
layerFilter: () => true,
hitTolerance: opts.hitTolerance ? opts.hitTolerance : 3,
checkWrapped: true
})
if (source instanceof olSourceCluster) {
// support for clustered feature clicks
const clusteredFeatures = source.getClosestFeatureToCoordinate(clickCoordinate).get('features')
features = clusteredFeatures
} else {
const sourceFeatures = source.getFeatures()
sourceFeatures.forEach(sourceFeature => {
// check if any feature on layer source is also at click location
const isAtPixel = featuresAtPixel ? featuresAtPixel.find(f => f === sourceFeature) : null
if (isAtPixel) features.push(sourceFeature)
})
}
if (features.length) return setParentLayer({ features, layer })
}
const vectorTileSelector = (layer, event, opts) => {
const { map, pixel } = event
// check for featuresAtPixel to account for hitTolerance
const featuresAtPixel = map.getFeaturesAtPixel(pixel, {
layerFilter: () => true,
hitTolerance: opts.hitTolerance ? opts.hitTolerance : 3,
checkWrapped: true
})
return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
const vectorTileSourceFeatures = await layer.getSource()
.getFeaturesInExtent(map.getView().calculateExtent(map.getSize()))
const matchingFeaturesAtPixel = vectorTileSourceFeatures.filter(sourceFeature => {
const { ol_uid } = sourceFeature // eslint-disable-line camelcase
let isFeatureAtClick = false
featuresAtPixel.forEach(feat => {
if (feat?.ol_uid === ol_uid) isFeatureAtClick = true // eslint-disable-line camelcase
})
return isFeatureAtClick
})
const { features } = await setParentLayer({ features: matchingFeaturesAtPixel, layer })
resolve({ features, layer })
})
}
/**
* Get all features for a given click event
* @function
* @category Popup
* @since 0.2.0
* @param {Object} event - An object with a `map` and `pixel` property
* @param {ol.Map} event.map - The openlayers map where the layer exists
* @param {Number[]} event.pixel - An array consisting of `x` and `y` pixel locations
* @param {Object} [opts] - Object of optional params
* @param {Number} [opts.hitTolerance = 3] - Additional area around features that is clickable to select them
* @returns {Promise[]} An array of promises, each of which resolve to an object `{ layer, features }`
*/
export const getLayersAndFeaturesForEvent = (event, opts = {}) => {
if (typeof event !== 'object') return ugh.error('getLayersAndFeaturesForEvent first arg must be an object') // eslint-disable-line
const { map, pixel } = event
if (!(map instanceof olMap) || !Array.isArray(pixel)) return ugh.error('getLayersAndFeaturesForEvent requires a valid openlayers map & pixel location (as an array)') // eslint-disable-line
const promises = map.getLayers().getArray().map(layer => {
if (layer instanceof olVectorTile) {
// handle vector tile features
return vectorTileSelector(layer, event, opts)
} else if (layer.isVectorLayer || layer instanceof olLayerVector || !layer.getLayerState().managed) { // layer.getLayerState().managed is an undocumented ol prop that lets us ignore select's vector layer
// handle non vector tile wfs layers
return wfsSelector(layer, event, opts)
}
}).filter(Boolean)
const wmsSelector = layer => {
if (layer.get('_ol_kit_parent')?.isGeoserverLayer) {
// this logic handles clicks on GeoserverLayers
const geoserverLayer = layer.get('_ol_kit_parent')
const coords = map.getCoordinateFromPixel(pixel)
const wmsPromise = new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
const rawFeatures = await geoserverLayer.fetchFeaturesAtClick(coords, map)
const { features } = await setParentLayer({ features: rawFeatures, layer })
resolve({ features, layer })
})
promises.push(wmsPromise)
}
}
// We have to look for ImageLayers with a parent GeoserverLayer this way, as the ImageLayer doesn't show up in map.getLayers()
map.forEachLayerAtPixel(pixel, wmsSelector)
return promises
}
/**
* Get the best position for the popup to be displayed given features
* @function
* @category Popup
* @param {Object} event - An object with an `event` and `pixel` property
* @param {ol.Map} event.map - The openlayers map where the layer exists
* @param {Number[]} event.pixel - An array consisting of `x` and `y` pixel locations
* @param {ol.Feature[]} features - An array of features around which the popup should position
* @param {Object} [opts]
* @param {Number} [opts.popupHeight = 280] - The height of the popup
* @param {Number} [opts.popupWidth = 280] - The width of the popup
* @param {Number} [opts.arrowHeight = 16] - The height of the popup's arrow/pointer
* @param {Number} [opts.navbarOffset = 55] - The height of the navbar
* @param {Number[]} [opts.viewPadding = [0, 0, 0, 0]] - An array of padding to apply to the best fit logic in top, right, bottom, left order
* @returns {Object} An object containing the arrow/pointer position, pixel location & if the popup will fit properly within the viewport
*/
export const getPopupPositionFromFeatures = (event, features = [], opts = {}) => {
if (typeof event !== 'object' || !Array.isArray(features)) return ugh.error('getPopupPositionFromFeatures first arg must be an object & second arg array of features')
const { map, pixel = [0, 0] } = event
if (!(map instanceof olMap)) return ugh.error('getPopupPositionFromFeatures requires a valid openlayers map as a property of the first arg')
if (!features.length) return { arrow: 'none', pixel, fits: false }
const arrowHeight = opts.arrowHeight || 16
const geoJSON = new GeoJSON({ featureProjection: 'EPSG:3857' })
const height = opts.popupHeight || 280
const width = opts.popupWidth || 280
const fullHeight = height + arrowHeight
const fullWidth = width + arrowHeight
const [mapX, mapY] = map.getSize()
const getPadding = (idx) => opts.viewPadding ? opts.viewPadding[idx] : calculateViewPadding(map)[idx]
const padding = {
top: getPadding(0),
right: getPadding(1),
bottom: getPadding(2),
left: getPadding(3)
}
// find bbox for passed features
const getFitsForFeatures = rawFeatures => {
// create a new array so original features are not mutated when _ol_kit_parent is nullified
const features = rawFeatures.map(feature => {
const clone = feature.clone()
// this removes a ref to _ol_kit_parent to solve circularJSON bug
clone.set('_ol_kit_parent', null)
clone.set('features', null)
return clone
})
const jsonFeatures = geoJSON.writeFeatures(features)
const [minX, minY, maxX, maxY] = bboxTurf(JSON.parse(jsonFeatures))
return {
top: getMidPixel([[minX, maxY], [maxX, maxY]]),
right: getMidPixel([[maxX, maxY], [maxX, minY]]),
bottom: getMidPixel([[minX, minY], [maxX, minY]]),
left: getMidPixel([[minX, minY], [minX, maxY]])
}
}
const getMidPixel = lineCoords => {
const centerFeature = centroid(lineString(lineCoords))
const coords = fromLonLat(centerFeature.geometry.coordinates)
return map.getPixelFromCoordinate(coords)
}
const fitsRight = ([x, y]) => x + fullWidth <= mapX - padding.right && y >= (height / 2) + padding.top && y + (height / 2) <= mapY - padding.bottom // eslint-disable-line
const fitsBelow = ([x, y]) => y + fullHeight - padding.bottom <= mapY && (width / 2) + padding.left <= x && x + (width / 2) - padding.right <= mapX // eslint-disable-line
const fitsAbove = ([x, y]) => y - padding.top >= fullHeight && (width / 2) + padding.left <= x && x + (width / 2) - padding.right <= mapX // eslint-disable-line
const fitsLeft = ([x, y]) => x + padding.left >= fullWidth && y >= (height / 2) + padding.top && y + (height / 2) <= mapY - padding.bottom // eslint-disable-line
// the order of these checks determine which side is tried first (right, left, top, and then bottom)
const getPosition = bbox => {
if (fitsRight(bbox.right)) return { arrow: 'left', pixel: bbox.right, fits: true }
if (fitsLeft(bbox.left)) return { arrow: 'right', pixel: bbox.left, fits: true }
if (fitsAbove(bbox.top)) return { arrow: 'bottom', pixel: bbox.top, fits: true }
if (fitsBelow(bbox.bottom)) return { arrow: 'top', pixel: bbox.bottom, fits: true }
if (opts.lastPosition) {
if (opts.lastPosition.arrow === 'left') return { arrow: 'left', pixel: bbox.right, fits: false }
if (opts.lastPosition.arrow === 'top') return { arrow: 'top', pixel: bbox.bottom, fits: false }
if (opts.lastPosition.arrow === 'bottom') return { arrow: 'bottom', pixel: bbox.top, fits: false }
if (opts.lastPosition.arrow === 'right') return { arrow: 'right', pixel: bbox.left, fits: false }
if (opts.lastPosition.arrow === 'none') return { arrow: 'none', pixel: opts.lastPosition.pixel, fits: false }
}
// if none of the above return, it doesn't fit on any side (it's on top of or within)
return { arrow: 'none', pixel, fits: false }
}
const fitsForFeature = getFitsForFeatures(features)
return getPosition(fitsForFeature)
}
/**
* Calculate bounding box of elements on page with _popup_boundary class and returns padding array excluding area of these elements
* @function
* @category Popup
* @param {olMap} map - An instance of an openlayers map
* @param {Object} opts
* @returns {Array} - Array of view padding pixel numbers: [top, right, bottom, left]
*/
export const calculateViewPadding = (map, opts = {}) => {
if (!(map instanceof olMap)) return ugh.error('calculateViewPadding requires a valid openlayers map as arg')
const viewPadding = [0, 0, 0, 0]
const navbarOffset = opts.navbarOffset || 55
const boundaryElements = Array.from(document.getElementsByClassName('_popup_boundary'))
const [mapX, mapY] = map.getSize()
boundaryElements.forEach(elem => {
const bbox = elem.getBoundingClientRect()
const isOffScreen = bbox.x < 0 || bbox.x >= mapX || bbox.y < navbarOffset || bbox.y >= mapY
if (!isOffScreen) {
if (bbox.right < mapX / 2) {
// set left padding for elements on left half of map
const newPadding = bbox.right
if (viewPadding[3] < newPadding) viewPadding[3] = newPadding
} else if (bbox.left > mapX / 2) {
// set right padding for elements on right half of map
const newPadding = mapX - bbox.left
if (viewPadding[1] < newPadding) viewPadding[1] = newPadding
} else if (bbox.top > mapY / 2) {
// set bottom padding for elements on bottom half of map
const newPadding = (mapY + navbarOffset) - bbox.top
if (viewPadding[2] < newPadding) viewPadding[2] = newPadding
} else if (bbox.y > navbarOffset) {
// set top padding for elements lower than navbar
const newPadding = bbox.bottom
if (viewPadding[0] < newPadding) viewPadding[0] = newPadding
}
}
})
return viewPadding
}
/**
* Remove blacklisted attributes (geom & geometry & _ol_kit*) from an object
* @function
* @category Popup
* @since 0.11.0
* @param {Object} properties - A feature attribute object
* @returns {Object} A filtered attribute object
*/
export const sanitizeProperties = properties => {
const blacklist = ['geom', 'geometry']
const sanitized = {}
for (const key in properties) {
const keyLower = key.toLowerCase()
if (!blacklist.includes(keyLower) && !keyLower.includes('_ol_kit')) {
sanitized[key] = properties[key]
}
}
return sanitized
}