/* Copyright (c) 2016 Jean-Marc VIGLINO,
released under the CeCILL-B license (French BSD license)
(http://www.cecill.info/licences/Licence_CeCILL-B_V1-en.txt).
*/
/*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
import { getDistance as ol_sphere_getDistance } from 'ol/sphere.js'
import { transform as ol_proj_transform } from 'ol/proj.js'
import ol_control_Control from 'ol/control/Control.js'
import ol_Feature from 'ol/Feature.js'
import ol_style_Fill from 'ol/style/Fill.js'
import { asString as ol_color_asString } from 'ol/color.js'
import ol_style_Style from 'ol/style/Style.js'
import ol_style_Stroke from 'ol/style/Stroke.js'
import ol_style_Text from 'ol/style/Text.js'
import ol_geom_LineString from 'ol/geom/LineString.js'
import { ol_coordinate_dist2d } from "../geom/GeomUtils.js"
import ol_ext_element from '../util/element.js'
/**
* @classdesc OpenLayers 3 Profile Control.
* Draw a profile of a feature (with a 3D geometry)
* @author Gastón Zalba https://github.com/GastonZalba
* @author Jean-Marc Viglino https://github.com/viglino
*
* @constructor
* @extends {ol_control_Control}
* @fires over
* @fires out
* @fires show
* @fires dragstart
* @fires dragging
* @fires dragend
* @fires dragcancel
* @param {Object=} options
* @param {string} options.className
* @param {String} options.title button title
* @param {ol.style.Style} [options.style] style to draw the profile, default darkblue
* @param {ol.style.Style} [options.selectStyle] style for selection, default darkblue fill
* @param {*} options.info keys/values for i19n
* @param {number} [options.width=300]
* @param {number} [options.height=150]
* @param {'metric'|'imperial'} [options.units='metric'] output system of measurement Note that input z coords are expected to be in meters in either mode (as determined by GPX, DEM, DSM, etc. standards).
* @param {ol.Feature} [options.feature] the feature to draw profile
* @param {boolean} [options.selectable=false] enable selection on the profil, default false
* @param {boolean} [options.zoomable=false] can zoom in the profile
* @param {string} [options.numberFormat] Convert numbers to a custom locale format, default is not used
* @param {string} [options.skipFirst] Skip the first/last n points of the profile to avaoid GPS spike on start, default 0
*/
var ol_control_Profile = class olcontrolProfile extends ol_control_Control {
constructor(options) {
options = options || {}
var element = document.createElement('div')
super({
element: element,
target: options.target
})
var self = this
this.info = options.info || ol_control_Profile.prototype.info
if (options.target) {
element.classList.add(options.className || 'ol-profile')
} else {
element.className = ((options.className || 'ol-profile') + ' ol-unselectable ol-control ol-collapsed').trim()
this.button = document.createElement('button')
this.button.title = options.title || 'Profile'
this.button.setAttribute('type', 'button')
var click_touchstart_function = function (e) {
self.toggle()
e.preventDefault()
}
this.button.addEventListener('click', click_touchstart_function)
this.button.addEventListener('touchstart', click_touchstart_function)
element.appendChild(this.button)
ol_ext_element.create('I', { parent: this.button })
}
// Drawing style
if (options.style instanceof ol_style_Style) {
this._style = options.style
} else {
this._style = new ol_style_Style({
text: new ol_style_Text(),
stroke: new ol_style_Stroke({
width: 1.5,
color: '#369'
})
})
}
if (!this._style.getText()) this._style.setText(new ol_style_Text())
// Selection style
if (options.selectStyle instanceof ol_style_Style) {
this._selectStyle = options.selectStyle
} else {
this._selectStyle = new ol_style_Style({
fill: new ol_style_Fill({ color: '#369' })
})
}
var div_inner = document.createElement("div")
div_inner.classList.add("ol-inner")
element.appendChild(div_inner)
var div = document.createElement("div")
div.style.position = "relative"
div_inner.appendChild(div)
var ratio = this.ratio = 2
this.canvas_ = document.createElement('canvas')
this.canvas_.width = (options.width || 300) * ratio
this.canvas_.height = (options.height || 150) * ratio
var styles = {
"msTransform": "scale(0.5,0.5)", "msTransformOrigin": "0 0",
"webkitTransform": "scale(0.5,0.5)", "webkitTransformOrigin": "0 0",
"mozTransform": "scale(0.5,0.5)", "mozTransformOrigin": "0 0",
"transform": "scale(0.5,0.5)", "transformOrigin": "0 0"
}
Object.keys(styles).forEach(function (style) {
if (style in self.canvas_.style) {
self.canvas_.style[style] = styles[style]
}
})
this.div_to_canvas_ = document.createElement("div")
div.appendChild(this.div_to_canvas_)
this.div_to_canvas_.style.width = this.canvas_.width / ratio + "px"
this.div_to_canvas_.style.height = this.canvas_.height / ratio + "px"
this.div_to_canvas_.appendChild(this.canvas_)
this.setProperties({
'units': options.units || 'metric',
'numberFormat': options.numberFormat,
'selectable': options.selectable,
'skipFirst': parseInt(options.skipFirst) || 0,
})
this._isMetric = this.get('units') === 'metric'
// Offset in px
this.margin_ = { top: 10 * ratio, left: 45 * ratio, bottom: 30 * ratio, right: 10 * ratio }
if (!this.info.ytitle)
this.margin_.left -= 20 * ratio
if (!this.info.xtitle)
this.margin_.bottom -= 20 * ratio
// Cursor
this.bar_ = document.createElement("div")
this.bar_.classList.add("ol-profilebar")
this.bar_.style.top = (this.margin_.top / ratio) + "px"
this.bar_.style.height = (this.canvas_.height - this.margin_.top - this.margin_.bottom) / ratio + "px"
div.appendChild(this.bar_)
this.cursor_ = document.createElement("div")
this.cursor_.classList.add("ol-profilecursor")
div.appendChild(this.cursor_)
this.popup_ = document.createElement("div")
this.popup_.classList.add("ol-profilepopup")
this.cursor_.appendChild(this.popup_)
// Track information
var t = document.createElement("table")
t.cellPadding = '0'
t.cellSpacing = '0'
t.style.clientWidth = this.canvas_.width / ratio + "px"
div.appendChild(t)
var firstTr = ol_ext_element.create("tr", {
className: 'track-info',
parent: t
})
ol_ext_element.create("td", {
html: (this.info.zmin || "Zmin") + ': ',
parent: firstTr
})
ol_ext_element.create("td", {
html: (this.info.zmax || "Zmax") + ': ',
parent: firstTr
})
var div_distance = ol_ext_element.create("td", { parent: firstTr })
div_distance.innerHTML = (this.info.distance || "Distance") + ': '
var div_time = ol_ext_element.create("td", { parent: firstTr })
div_time.innerHTML = (this.info.time || "Time") + ': '
var optionTr = ol_ext_element.create("tr", {
className: 'track-info track-options',
parent: t
})
ol_ext_element.create("td", {
html: (this.info.elevation || "Elevation gain") + ':
/ ' ,
colspan: 2,
parent: optionTr
})
/*
ol_ext_element.create("td", {
html: (this.info.elevloss || "Elevation loss") + ': ' ,
parent: optionTr
})
*/
ol_ext_element.create("td", {
html: (this.info.maxslope || "Max. slope") + ': ' ,
parent: optionTr
})
ol_ext_element.create("td", {
html: (this.info.avgslope || "Average slope") + ': ' ,
parent: optionTr
})
// Point information
var secondTr = document.createElement("tr")
secondTr.classList.add("point-info")
t.appendChild(secondTr)
var div_altitude = document.createElement("td")
div_altitude.innerHTML = (this.info.altitude || "Altitude") + ': '
secondTr.appendChild(div_altitude)
var div_distance2 = document.createElement("td")
div_distance2.innerHTML = (this.info.distance || "Distance") + ': '
secondTr.appendChild(div_distance2)
var div_time2 = document.createElement("td")
div_time2.innerHTML = (this.info.time || "Time") + ': '
secondTr.appendChild(div_time2)
// Array of data
this.tab_ = []
// Show feature
if (options.feature) {
this.setGeometry(options.feature)
}
// Zoom on profile
if (options.zoomable) {
this.set('selectable', true)
var start, geom
this.on('change:geometry', function () {
geom = null
})
this.on('dragstart', function (e) {
start = e.index
})
this.on('dragend', function (e) {
if (Math.abs(start - e.index) > 10) {
if (!geom) {
var bt = ol_ext_element.create('BUTTON', {
parent: element,
className: 'ol-zoom-out',
click: function (e) {
e.stopPropagation()
e.preventDefault()
if (geom) {
this.dispatchEvent({ type: 'zoom' })
this.setGeometry(geom, this._geometry[1])
}
element.removeChild(bt)
}.bind(this)
})
}
var saved = geom || this._geometry[0]
var g = new ol_geom_LineString(this.getSelection(start, e.index))
this.setGeometry(g, this._geometry[1])
geom = saved
this.dispatchEvent({ type: 'zoom', geometry: g, start: start, end: e.index })
}
}.bind(this))
}
// Add listener on target elements
if (options.target) {
this._addListeners()
}
}
/** Add canvas listeners
* @private
*/
_addListeners() {
// prevent multi listeners
if (!this.onMoveBinded) {
this.onMoveBinded = this.onMove.bind(this)
this.div_to_canvas_.addEventListener('pointerdown', this.onMoveBinded)
this.div_to_canvas_.addEventListener('mousemove', this.onMoveBinded)
this.div_to_canvas_.addEventListener('touchmove', this.onMoveBinded)
document.addEventListener('pointerup', this.onMoveBinded)
}
}
/** Remove canvas listeners
* @private
*/
_removeListeners() {
if (this.onMoveBinded) {
this.div_to_canvas_.removeEventListener('pointerdown', this.onMoveBinded)
this.div_to_canvas_.removeEventListener('mousemove', this.onMoveBinded)
this.div_to_canvas_.removeEventListener('touchmove', this.onMoveBinded)
document.removeEventListener('pointerup', this.onMoveBinded)
this.onMoveBinded = null
}
}
/** Show popup info
* @param {string} info to display as a popup
* @api stable
*/
popup(info) {
this.popup_.innerHTML = info
}
/** Show point on profile
* @param {*} p
* @param {number} dx
* @private
*/
_drawAt(p, dx) {
if (p) {
this.cursor_.style.left = dx + "px"
this.cursor_.style.top = (this.canvas_.height - this.margin_.bottom + p[1] * this.scale_[1] + this.dy_) / this.ratio + "px"
this.cursor_.style.display = "block"
this.bar_.parentElement.classList.add("over")
this.bar_.style.left = dx + "px"
this.bar_.style.display = "block"
var zunit = this._isMetric ? ol_control_Profile.prototype.Unit.Meter : ol_control_Profile.prototype.Unit.Foot
var zvalue = this._unitsConversion(p[1], zunit)
this.element.querySelector(".point-info .z").textContent = typeof zvalue === 'number' ? this._numberFormat(zvalue, this.get('zDigitsHover')) + zunit : '-'
var xunit = this._isMetric ? ol_control_Profile.prototype.Unit.Meter : ol_control_Profile.prototype.Unit.Foot
var xvalue = this._unitsConversion(p[0], xunit)
if (this._isMetric) xunit = (xvalue > ol_control_Profile.prototype.KILOMETER_VALUE) ? ol_control_Profile.prototype.Unit.Kilometer : ol_control_Profile.prototype.Unit.Meter
else xunit = (xvalue > ol_control_Profile.prototype.MILE_VALUE) ? ol_control_Profile.prototype.Unit.Mile : ol_control_Profile.prototype.Unit.Foot
xvalue = this._unitsConversion(p[0], xunit)
this.element.querySelector(".point-info .dist").textContent = typeof xvalue === 'number' ? this._numberFormat(xvalue, this.get('xDigitsHover')) + xunit : '-'
this.element.querySelector(".point-info .time").textContent = p[2]
if (dx > this.canvas_.width / this.ratio / 2)
this.popup_.classList.add('ol-left')
else
this.popup_.classList.remove('ol-left')
} else {
this.cursor_.style.display = "none"
this.bar_.style.display = 'none'
this.cursor_.style.display = 'none'
this.bar_.parentElement.classList.remove("over")
}
}
/** Show point at coordinate or a distance on the profile
* @param { ol.coordinates|number } where a coordinate or a distance from begining, if none it will hide the point
* @return { ol.coordinates } current point
*/
showAt(where) {
var i, p, p0, d0 = Infinity
if (typeof (where) === 'undefined') {
if (this.bar_.parentElement.classList.contains("over")) {
// Remove it
this._drawAt()
}
} else if (where.length) {
// Look for closest the point
for (i = 1; p = this.tab_[i]; i++) {
var d = ol_coordinate_dist2d(p[3], where)
if (d < d0) {
p0 = p
d0 = d
}
}
} else {
for (i = 0; p = this.tab_[i]; i++) {
p0 = p
if (p[0] >= where) {
break
}
}
}
if (p0) {
var dx = (p0[0] * this.scale_[0] + this.margin_.left) / this.ratio
this._drawAt(p0, dx)
return p0[3]
}
return null
}
/** Show point at a time on the profile
* @param { Date|number } time a Date or a DateTime (in s) to show the profile on, if none it will hide the point
* @param { booelan } delta true if time is a delta from the start, default false
* @return { ol.coordinates } current point
*/
showAtTime(time, delta) {
var i, p, p0
if (time instanceof Date) {
time = time.getTime() / 1000
} else if (delta) {
time += this.tab_[0][3][3]
}
if (typeof (time) === 'undefined') {
if (this.bar_.parentElement.classList.contains("over")) {
// Remove it
this._drawAt()
}
} else {
for (i = 0; p = this.tab_[i]; i++) {
p0 = p
if (p[3][3] >= time) {
break
}
}
}
if (p0) {
var dx = (p0[0] * this.scale_[0] + this.margin_.left) / this.ratio
this._drawAt(p0, dx)
return p0[3]
}
return null
}
/** Get the point at a given time on the profile
* @param { number } time time at which to show the point
* @return { ol.coordinates } current point
*/
pointAtTime(time) {
var i, p
// Look for closest the point
for (i = 1; p = this.tab_[i]; i++) {
var t = p[3][3]
if (t >= time) {
// Previous one ?
var pt = this.tab_[i - 1][3]
if ((pt[3] + t) / 2 < time)
return pt
else
return p
}
}
return this.tab_[this.tab_.length - 1][3]
}
/** Mouse move over canvas
*/
onMove(e) {
if (!this.tab_.length)
return
var box_canvas = this.canvas_.getBoundingClientRect()
var pos = {
top: box_canvas.top + window.pageYOffset - document.documentElement.clientTop,
left: box_canvas.left + window.pageXOffset - document.documentElement.clientLeft
}
var pageX = e.pageX
|| (e.touches && e.touches.length && e.touches[0].pageX)
|| (e.changedTouches && e.changedTouches.length && e.changedTouches[0].pageX)
var pageY = e.pageY
|| (e.touches && e.touches.length && e.touches[0].pageY)
|| (e.changedTouches && e.changedTouches.length && e.changedTouches[0].pageY)
var dx = pageX - pos.left
var dy = pageY - pos.top
var ratio = this.ratio
if (dx > this.margin_.left / ratio - 20 && dx < (this.canvas_.width - this.margin_.right) / ratio + 8
&& dy > this.margin_.top / ratio && dy < (this.canvas_.height - this.margin_.bottom) / ratio) {
var d = (dx * ratio - this.margin_.left) / this.scale_[0]
var p0 = this.tab_[0]
var index, p
for (index = 1; p = this.tab_[index]; index++) {
if (p[0] >= d) {
if (d < (p[0] + p0[0]) / 2) {
index = 0
p = p0
}
break
}
}
if (!p)
p = this.tab_[this.tab_.length - 1]
dx = Math.max(this.margin_.left / ratio, Math.min(dx, (this.canvas_.width - this.margin_.right) / ratio))
// invalid y value
if (typeof p[1] === 'undefined') return;
this._drawAt(p, dx)
this.dispatchEvent({ type: 'over', click: e.type === 'click', index: index, coord: p[3], time: p[2], distance: p[0] })
// Handle drag / click
switch (e.type) {
case 'pointerdown': {
this._dragging = {
event: { type: 'dragstart', index: index, coord: p[3], time: p[2], distance: p[0] },
pageX: pageX,
pageY: pageY
}
break
}
case 'pointerup': {
if (this._dragging && this._dragging.pageX) {
if (Math.abs(this._dragging.pageX - pageX) < 3 && Math.abs(this._dragging.pageY - pageY) < 3) {
this.dispatchEvent({ type: 'click', index: index, coord: p[3], time: p[2], distance: p[0] })
this.refresh()
}
} else {
this.dispatchEvent({ type: 'dragend', index: index, coord: p[3], time: p[2], distance: p[0] })
}
this._dragging = false
break
}
default: {
if (this._dragging) {
if (this._dragging.pageX) {
if (Math.abs(this._dragging.pageX - pageX) > 3 || Math.abs(this._dragging.pageY - pageY) > 3) {
this._dragging.pageX = this._dragging.pageY = false
this.dispatchEvent(this._dragging.event)
}
} else {
this.dispatchEvent({ type: 'dragging', index: index, coord: p[3], time: p[2], distance: p[0] })
var min = Math.min(this._dragging.event.index, index)
var max = Math.max(this._dragging.event.index, index)
this.refresh()
if (this.get('selectable'))
this._drawGraph(this.tab_.slice(min, max), this._selectStyle)
}
}
break
}
}
} else {
if (this.bar_.parentElement.classList.contains('over')) {
this._drawAt()
this.dispatchEvent({ type: 'out' })
}
if (e.type === 'pointerup' && this._dragging) {
this.dispatchEvent({ type: 'dragcancel' })
this._dragging = false
}
}
}
/** Show panel
* @api stable
*/
show() {
this.element.classList.remove("ol-collapsed")
this._addListeners()
this.dispatchEvent({ type: 'show', show: true })
}
/** Hide panel
* @api stable
*/
hide() {
this.element.classList.add("ol-collapsed")
this._removeListeners()
this.dispatchEvent({ type: 'show', show: false })
}
/** Toggle panel
* @api stable
*/
toggle() {
if (this.isShown()) this.hide()
else this.show()
}
/** Is panel visible
*/
isShown() {
return (!this.element.classList.contains("ol-collapsed"))
}
/** Get selection
* @param {number} starting point
* @param {number} ending point
* @return {Array