1067 lines
37 KiB
JavaScript
1067 lines
37 KiB
JavaScript
/* 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") + ': <span class="zmin zinfo"></span>',
|
|
parent: firstTr
|
|
})
|
|
ol_ext_element.create("td", {
|
|
html: (this.info.zmax || "Zmax") + ': <span class="zmax"></span>',
|
|
parent: firstTr
|
|
})
|
|
|
|
var div_distance = ol_ext_element.create("td", { parent: firstTr })
|
|
div_distance.innerHTML = (this.info.distance || "Distance") + ': <span class="dist"></span>'
|
|
|
|
var div_time = ol_ext_element.create("td", { parent: firstTr })
|
|
div_time.innerHTML = (this.info.time || "Time") + ': <span class="time"></span>'
|
|
|
|
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") + ': <br/><span class="elevationGain"></span> / <span class="elevationLoss"></span>' ,
|
|
colspan: 2,
|
|
parent: optionTr
|
|
})
|
|
/*
|
|
ol_ext_element.create("td", {
|
|
html: (this.info.elevloss || "Elevation loss") + ': <span class="elevationLoss"></span>' ,
|
|
parent: optionTr
|
|
})
|
|
*/
|
|
ol_ext_element.create("td", {
|
|
html: (this.info.maxslope || "Max. slope") + ': <span class="maxSlope"></span>' ,
|
|
parent: optionTr
|
|
})
|
|
ol_ext_element.create("td", {
|
|
html: (this.info.avgslope || "Average slope") + ': <span class="avgSlope"></span>' ,
|
|
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") + ': <span class="z"></span>'
|
|
secondTr.appendChild(div_altitude)
|
|
var div_distance2 = document.createElement("td")
|
|
div_distance2.innerHTML = (this.info.distance || "Distance") + ': <span class="dist"></span>'
|
|
secondTr.appendChild(div_distance2)
|
|
var div_time2 = document.createElement("td")
|
|
div_time2.innerHTML = (this.info.time || "Time") + ': <span class="time"></span>'
|
|
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
|
|
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
|
|
|
|
var 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<ol.coordinate>}
|
|
*/
|
|
getSelection(start, end) {
|
|
var sel = []
|
|
var min = Math.max(Math.min(start, end), 0)
|
|
var max = Math.min(Math.max(start, end), this.tab_.length - 1)
|
|
for (var i = min; i <= max; i++) {
|
|
sel.push(this.tab_[i][3])
|
|
}
|
|
return sel
|
|
}
|
|
/** Draw the graph
|
|
* @private
|
|
*/
|
|
_drawGraph(t, style) {
|
|
if (!t.length)
|
|
return
|
|
|
|
function closeSegment(inX, outX) {
|
|
if (style.getStroke()) {
|
|
var stringColor = style.getStroke().getColor()
|
|
ctx.strokeStyle = stringColor ? ol_color_asString(stringColor) : '#000'
|
|
ctx.lineWidth = style.getStroke().getWidth() * ratio
|
|
ctx.setLineDash([])
|
|
ctx.stroke()
|
|
}
|
|
|
|
if (style.getFill()) {
|
|
var fillColor = style.getFill().getColor()
|
|
ctx.fillStyle = fillColor ? ol_color_asString(fillColor) : '#000'
|
|
ctx.Style = fillColor ? ol_color_asString(fillColor) : '#000'
|
|
ctx.lineTo(outX * scx, 0)
|
|
ctx.lineTo(inX * scx, 0)
|
|
ctx.fill()
|
|
}
|
|
}
|
|
|
|
var ctx = this.canvas_.getContext('2d')
|
|
var scx = this.scale_[0]
|
|
var scy = this.scale_[1]
|
|
var dy = this.dy_
|
|
var ratio = this.ratio
|
|
var i, p, inX, outX, hasToCloseSegment = false
|
|
|
|
// Draw Path
|
|
ctx.beginPath()
|
|
for (i = 0; p = t[i]; i++) {
|
|
if (i == 0)
|
|
ctx.moveTo(p[0] * scx, p[1] * scy + dy)
|
|
else {
|
|
if (p[1]) {
|
|
hasToCloseSegment = true;
|
|
if (!inX) inX = p[0];
|
|
outX = p[0];
|
|
ctx.lineTo(p[0] * scx, p[1] * scy + dy)
|
|
} else {
|
|
if (hasToCloseSegment) {
|
|
closeSegment(inX, outX)
|
|
hasToCloseSegment = false
|
|
inX = null
|
|
outX = null
|
|
}
|
|
if (t[i+1]) {
|
|
ctx.beginPath()
|
|
ctx.moveTo(t[i+1][0] * scx, t[i+1][1] * scy + dy)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasToCloseSegment)
|
|
closeSegment(inX, outX)
|
|
|
|
|
|
}
|
|
/**
|
|
* Set the geometry to draw the profile.
|
|
* @param {ol.Feature|ol.geom.Geometry} f the feature.
|
|
* @param {Object=} options
|
|
* @param {ol.ProjectionLike} [options.projection] feature projection, default projection of the map
|
|
* @param {('m'|'km'|'ft'|'mi')} [options.zunit='m'] 'm', 'km', 'ft' or 'mi', default 'm' or 'ft' according to the System of measurement
|
|
* @param {('m'|'km'|'ft'|'mi')} [options.unit='km'] 'm', 'km', 'ft' or 'mi', default 'km' or 'mi' according to the System of measurement
|
|
* @param {Number|undefined} [options.zmin=0] default 0
|
|
* @param {Number|undefined} [options.zmax] default max Z of the feature
|
|
* @param {integer|undefined} [options.zDigits=0] number of digits for z graduation, default is calculated according to the value range
|
|
* @param {integer|undefined} [options.xDigits=1] number of digits for x-axis (distance), default 1
|
|
* @param {number} [options.zDigitsHover=2] Decimals number while hovering the profile graph, default 2
|
|
* @param {number} [options.xDigitsHover=1] Decimals number while hovering the profile graph, default 1
|
|
* @param {integer|undefined} [options.zMaxChars] maximum number of chars to be used for z graduation before switching to scientific notation
|
|
* @param {Number|undefined} [options.graduation=100] length of each z graduation step, default 100. If `zSteps` is provided, this is not used
|
|
* @param {integer|undefined} [options.amplitude] amplitude of the altitude, default zmax-zmin
|
|
* @param {integer|undefined} [options.xSteps] number of steps at the x-axis (distance), default 10. If not provided, default is calculated from the distance
|
|
* @param {integer|undefined} [options.zSteps] number of steps at the amplitude scale. If not provided, default is calculated from graduation
|
|
* @api stable
|
|
*/
|
|
setGeometry(g, options) {
|
|
if (!options)
|
|
options = {}
|
|
if (g instanceof ol_Feature)
|
|
g = g.getGeometry()
|
|
this._geometry = [g, options]
|
|
|
|
// No Z
|
|
if (!/Z/.test(g.getLayout()))
|
|
return
|
|
// No time
|
|
if (/M/.test(g.getLayout())) {
|
|
this.element.querySelector(".time").parentElement.style.display = 'table-cell'
|
|
} else {
|
|
this.element.querySelector(".time").parentElement.style.display = 'none'
|
|
}
|
|
|
|
// Coords
|
|
var c = g.getCoordinates()
|
|
switch (g.getType()) {
|
|
case "LineString": break
|
|
case "MultiLineString": c = c[0]; break
|
|
default: return
|
|
}
|
|
|
|
// Distance beetween 2 coords
|
|
var proj = options.projection || this.getMap().getView().getProjection()
|
|
function dist2d(p1, p2) {
|
|
return ol_sphere_getDistance(
|
|
ol_proj_transform(p1, proj, 'EPSG:4326'),
|
|
ol_proj_transform(p2, proj, 'EPSG:4326')
|
|
)
|
|
}
|
|
|
|
function getTime(t0, t1) {
|
|
if (!t0 || !t1)
|
|
return "-"
|
|
var dt = (t1 - t0) / 60 // mn
|
|
var ti = Math.trunc(dt / 60)
|
|
var mn = Math.trunc(dt - ti * 60)
|
|
return ti + "h" + (mn < 10 ? "0" : "") + mn + "mn"
|
|
}
|
|
|
|
// Calculate [distance, altitude, time, point] for each points
|
|
var zmin = Infinity, zmax = -Infinity
|
|
var elevationGain = 0, elevationLoss = 0
|
|
var maxSlope = 0, avgSlope = 0;
|
|
var i, p, d, z, ti, t = this.tab_ = []
|
|
for (i = 0, p; p = c[i]; i++) {
|
|
z = p[2]
|
|
if (z < zmin) {
|
|
zmin = z
|
|
}
|
|
if (z > zmax) {
|
|
zmax = z
|
|
}
|
|
if (i == 0) {
|
|
d = 0
|
|
} else {
|
|
var di = dist2d(c[i - 1], p)
|
|
var dz = z - c[i - 1][2]
|
|
var slope = di ? Math.abs(dz / di) : 0
|
|
// ignore first n points to avoid GPS spike
|
|
if (i < this.get('skipFirst') || i > c.length - this.get('skipFirst')) {
|
|
slope = 0;
|
|
}
|
|
maxSlope = Math.max(maxSlope, slope);
|
|
avgSlope += slope;
|
|
d += di
|
|
elevationGain += Math.max(0, z - c[i - 1][2])
|
|
elevationLoss += Math.max(0, c[i - 1][2] - z)
|
|
}
|
|
ti = getTime(c[0][3], p[3])
|
|
t.push([d, z, ti, p])
|
|
}
|
|
|
|
this._z = [zmin, zmax]
|
|
this._elevation = [elevationGain, elevationLoss]
|
|
this._maxSlope = 100 * maxSlope
|
|
this._avgSlope = 100 * avgSlope / t.length;
|
|
|
|
this.setProperties({
|
|
'graduation': options.graduation || 100,
|
|
'zmin': options.zmin,
|
|
'zmax': options.zmax,
|
|
'amplitude': options.amplitude,
|
|
'unit': options.unit,
|
|
'zunit': options.zunit,
|
|
'zDigits': typeof options.zDigits === 'number' ? options.zDigits : 0,
|
|
'xDigits': typeof options.xDigits === 'number' ? options.xDigits : 1,
|
|
'zDigitsHover': typeof options.zDigitsHover === 'number' ? options.zDigitsHover : 2,
|
|
'xDigitsHover': typeof options.xDigitsHover === 'number' ? options.xDigitsHover : 1,
|
|
'zMaxChars': options.zMaxChars,
|
|
'xSteps': options.xSteps,
|
|
'zSteps': options.zSteps,
|
|
})
|
|
|
|
this.dispatchEvent({ type: 'change:geometry', geometry: g })
|
|
|
|
this.refresh()
|
|
}
|
|
/** Refresh the profile
|
|
*/
|
|
refresh() {
|
|
var canvas = this.canvas_
|
|
var ctx = canvas.getContext('2d')
|
|
var w = canvas.width
|
|
var h = canvas.height
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
|
ctx.clearRect(0, 0, w, h)
|
|
|
|
var zmin = this._z[0]
|
|
var zmax = this._z[1]
|
|
var t = this.tab_
|
|
|
|
var d = t[t.length - 1][0]
|
|
var ti = t[t.length - 1][2]
|
|
var i
|
|
|
|
if (!d) {
|
|
console.error('[ol/control/Profile] no data...', t)
|
|
return
|
|
}
|
|
|
|
// Margin
|
|
ctx.setTransform(1, 0, 0, 1, this.margin_.left, h - this.margin_.bottom)
|
|
var ratio = this.ratio
|
|
|
|
w -= this.margin_.right + this.margin_.left
|
|
h -= this.margin_.top + this.margin_.bottom
|
|
// Draw axes
|
|
var textFillColor = this._style.getText().getFill().getColor()
|
|
ctx.strokeStyle = textFillColor ? ol_color_asString(textFillColor) : '#000'
|
|
ctx.lineWidth = 0.5 * ratio
|
|
ctx.beginPath()
|
|
ctx.moveTo(0, 0); ctx.lineTo(0, -h)
|
|
ctx.moveTo(0, 0); ctx.lineTo(w, 0)
|
|
ctx.stroke()
|
|
|
|
// Info
|
|
var zunit = (this._isMetric) ? ol_control_Profile.prototype.Unit.Meter : ol_control_Profile.prototype.Unit.Foot
|
|
|
|
var zminConverted = this._unitsConversion(zmin, zunit)
|
|
this.element.querySelector(".track-info .zmin").textContent = this._numberFormat(zminConverted, this.get('zDigitsHover')) + zunit
|
|
var zmaxConverted = this._unitsConversion(zmax, zunit)
|
|
this.element.querySelector(".track-info .zmax").textContent = this._numberFormat(zmaxConverted, this.get('zDigitsHover')) + zunit
|
|
|
|
var elevGainConverted = this._unitsConversion(this._elevation[0], zunit)
|
|
this.element.querySelector(".track-options .elevationGain").textContent = this._numberFormat(elevGainConverted, this.get('zDigitsHover')) + zunit
|
|
var elevLossConverted = this._unitsConversion(this._elevation[1], zunit)
|
|
this.element.querySelector(".track-options .elevationLoss").textContent = this._numberFormat(-elevLossConverted, this.get('zDigitsHover')) + zunit
|
|
this.element.querySelector(".track-options .maxSlope").textContent = this._numberFormat(this._maxSlope, 0) + "%"
|
|
this.element.querySelector(".track-options .avgSlope").textContent = this._numberFormat(this._avgSlope, 0) + "%"
|
|
|
|
var dunit;
|
|
if (this._isMetric) {
|
|
dunit = (d > 1000) ? ol_control_Profile.prototype.Unit.Kilometer : ol_control_Profile.prototype.Unit.Meter
|
|
} else {
|
|
dunit = (d > ol_control_Profile.prototype.MILE_VALUE) ? ol_control_Profile.prototype.Unit.Mile : ol_control_Profile.prototype.Unit.Foot
|
|
}
|
|
var dConverted = this._unitsConversion(d, dunit)
|
|
this.element.querySelector(".track-info .dist").textContent = this._numberFormat(dConverted, this.get('xDigitsHover')) + dunit
|
|
|
|
this.element.querySelector(".track-info .time").textContent = ti
|
|
|
|
var zSteps = this.get('zSteps')
|
|
var grad
|
|
|
|
if (zSteps) {
|
|
zmax = Math.ceil(zmax)
|
|
zmin = Math.floor(zmin * 10) / 10
|
|
var amp = this.get('amplitude') || (zmax - zmin)
|
|
grad = amp / (zSteps - 1)
|
|
} else {
|
|
// Set graduation
|
|
grad = this.get('graduation')
|
|
while (true) {
|
|
zmax = Math.ceil(zmax / grad) * grad
|
|
zmin = Math.floor(zmin / grad) * grad
|
|
var nbgrad = (zmax - zmin) / grad
|
|
if (h / nbgrad < 15 * ratio) {
|
|
grad *= 2
|
|
}
|
|
else
|
|
break
|
|
}
|
|
}
|
|
|
|
// Set amplitude
|
|
if (typeof (this.get('zmin')) == 'number' && zmin > this.get('zmin'))
|
|
zmin = this.get('zmin')
|
|
if (typeof (this.get('zmax')) == 'number' && zmax < this.get('zmax'))
|
|
zmax = this.get('zmax')
|
|
var amplitude = this.get('amplitude')
|
|
if (amplitude) {
|
|
zmax = Math.max(zmin + amplitude, zmax)
|
|
}
|
|
|
|
// Scales lines
|
|
var scx = w / d
|
|
var scy = -h / (zmax - zmin)
|
|
var dy = this.dy_ = -zmin * scy
|
|
this.scale_ = [scx, scy]
|
|
|
|
this._drawGraph(t, this._style)
|
|
|
|
// Draw
|
|
ctx.textAlign = 'right'
|
|
ctx.textBaseline = 'top'
|
|
var textStrokeColor = this._style.getText().getFill().getColor()
|
|
ctx.fillStyle = textStrokeColor ? ol_color_asString(textStrokeColor) : '#000'
|
|
|
|
// Scale Z
|
|
ctx.beginPath()
|
|
var zDigits = this.get('zDigits')
|
|
var zunit = this.get('zunit') || this._isMetric ? ol_control_Profile.prototype.Unit.Meter : ol_control_Profile.prototype.Unit.Foot
|
|
|
|
var exp = null
|
|
if (typeof (this.get('zMaxChars')) == 'number') {
|
|
var usedChars
|
|
var zminCon = this._unitsConversion(zmin, zunit)
|
|
var zmaxCon = this._unitsConversion(zmax, zunit)
|
|
|
|
usedChars = Math.max(zminCon.toFixed(1).length, zmaxCon.toFixed(1).length)
|
|
|
|
if (this.get('zMaxChars') < usedChars) {
|
|
exp = Math.floor(Math.log10(Math.max(Math.abs(zmin), Math.abs(zmax), Number.MIN_VALUE)))
|
|
ctx.font = 'bold ' + (9 * ratio) + 'px arial'
|
|
ctx.fillText(exp.toString(), -8 * ratio, 8 * ratio)
|
|
var expMetrics = ctx.measureText(exp.toString())
|
|
var expWidth = expMetrics.width
|
|
var expHeight = expMetrics.actualBoundingBoxAscent + expMetrics.actualBoundingBoxDescent
|
|
ctx.font = 'bold ' + (12 * ratio) + 'px arial'
|
|
ctx.fillText("10", -8 * ratio - expWidth, 8 * ratio + 0.5 * expHeight)
|
|
}
|
|
}
|
|
ctx.font = (10 * ratio) + 'px arial'
|
|
ctx.textBaseline = 'middle'
|
|
for (i = zmin; i <= zmax; i += grad) {
|
|
var zunitNumber = this._unitsConversion(i, zunit)
|
|
if (exp !== null) {
|
|
var baseNumber = zunitNumber / Math.pow(10, exp)
|
|
var nbDigits = this.get('zMaxChars') - Math.floor(Math.log10(Math.max(Math.abs(baseNumber), 1)) + 1) - 1
|
|
if (baseNumber < 0)
|
|
nbDigits -= 1
|
|
|
|
ctx.fillText(baseNumber.toFixed(Math.max(nbDigits, 0)), -4 * ratio, i * scy + dy)
|
|
} else {
|
|
|
|
if (typeof zDigits == 'number') {
|
|
zunitNumber = this._numberFormat(zunitNumber, zDigits);
|
|
} else {
|
|
// If `zDigits` is not provided, it's calculated according to the range of values (after the unit conversion)
|
|
// If the diferrece between zmax and zmin is less than `zdif`, use decimals
|
|
var zdif = 10;
|
|
zunitNumber = ((this._unitsConversion(zmax, zunit) - this._unitsConversion(zmin, zunit)) > zdif)
|
|
? this._numberFormat(zunitNumber, 0)
|
|
: this._numberFormat(zunitNumber, 2);
|
|
}
|
|
ctx.fillText(zunitNumber, -4 * ratio, i * scy + dy)
|
|
}
|
|
ctx.moveTo(-2 * ratio, i * scy + dy)
|
|
if (i != 0)
|
|
ctx.lineTo(d * scx, i * scy + dy)
|
|
else
|
|
ctx.lineTo(0, i * scy + dy)
|
|
}
|
|
// Scale X
|
|
ctx.textAlign = "center"
|
|
ctx.textBaseline = "top"
|
|
ctx.setLineDash([ratio, 3 * ratio])
|
|
var unit = this.get('unit') || ((this._isMetric) ? ol_control_Profile.prototype.Unit.Kilometer : ol_control_Profile.prototype.Unit.Mile)
|
|
var stepsX = this.get('xSteps')
|
|
var xDigits = this.get('xDigits')
|
|
var maxLimit = unit === ol_control_Profile.prototype.Unit.Mile ? ol_control_Profile.prototype.MILE_VALUE : 1000
|
|
var step
|
|
|
|
if (d < maxLimit) {
|
|
// For small distances use the smallers units
|
|
unit = this._isMetric ? ol_control_Profile.prototype.Unit.Meter : ol_control_Profile.prototype.Unit.Foot
|
|
}
|
|
|
|
if (typeof stepsX === 'number') {
|
|
step = (d / (stepsX - 1))
|
|
} else {
|
|
if (d > maxLimit) {
|
|
step = Math.round(d / 1000) * 100
|
|
if (step > 1000)
|
|
step = Math.ceil(step / 1000) * 1000
|
|
} else {
|
|
if (d > 100)
|
|
step = Math.round(d / 100) * 10
|
|
else if (d > 10)
|
|
step = Math.round(d / 10)
|
|
else if (d > 1)
|
|
step = Math.round(d) / 10
|
|
else
|
|
step = d
|
|
}
|
|
}
|
|
|
|
// Distances / X
|
|
for (i = 0; i <= d; i += step) {
|
|
var num = Number(this._unitsConversion(i, unit).toFixed(xDigits))
|
|
var txt = this._numberFormat(num, xDigits)
|
|
//if (i+step>d) txt += " "+ (options.zunits || "km");
|
|
ctx.fillText(txt, i * scx, 4 * ratio)
|
|
ctx.moveTo(i * scx, 3 * ratio); ctx.lineTo(i * scx, 0)
|
|
}
|
|
ctx.font = (12 * ratio) + "px arial"
|
|
var xOldMethod = this.info.xtitle.search('(km)') // Support for old naming convention and replace unit method
|
|
var xtext = (xOldMethod !== -1) ? this.info.xtitle.replace('(km)', "(" + unit + ")") : this.info.xtitle + " (" + unit + ")"
|
|
ctx.fillText(xtext, w / 2, 18 * ratio)
|
|
ctx.save()
|
|
ctx.rotate(-Math.PI / 2)
|
|
var yOldMethod = this.info.xtitle.search('(m)') // Support for old naming convention and replace unit method
|
|
var ytext = (yOldMethod !== -1) ? this.info.ytitle.replace('(m)', "(" + zunit + ")") : this.info.ytitle + " (" + zunit + ")"
|
|
ctx.fillText(ytext, h / 2, -this.margin_.left)
|
|
ctx.restore()
|
|
|
|
ctx.stroke()
|
|
}
|
|
/** Get profile image
|
|
* @param {string|undefined} type image format or 'canvas' to get the canvas image, default image/png.
|
|
* @param {Number|undefined} encoderOptions between 0 and 1 indicating image quality image/jpeg or image/webp, default 0.92.
|
|
* @return {string} requested data uri
|
|
* @api stable
|
|
*/
|
|
getImage(type, encoderOptions) {
|
|
if (type === "canvas")
|
|
return this.canvas_
|
|
return this.canvas_.toDataURL(type, encoderOptions)
|
|
}
|
|
|
|
/**
|
|
* Convert meters to another system of measurement or unit
|
|
* @param {number} nMeters
|
|
* @param {('m'|'km'|'ft'|'mi')} targetUnit default is m
|
|
* @return {number}
|
|
* @api stable
|
|
*/
|
|
_unitsConversion(nMeters, targetUnit) {
|
|
targetUnit = targetUnit || ol_control_Profile.prototype.Unit.Meter
|
|
switch (targetUnit) {
|
|
case ol_control_Profile.prototype.Unit.Kilometer:
|
|
return nMeters / ol_control_Profile.prototype.KILOMETER_VALUE;
|
|
case ol_control_Profile.prototype.Unit.Foot:
|
|
return nMeters / ol_control_Profile.prototype.FOOT_VALUE
|
|
case ol_control_Profile.prototype.Unit.Mile:
|
|
return nMeters / ol_control_Profile.prototype.MILE_VALUE
|
|
case ol_control_Profile.prototype.Unit.Meter:
|
|
default:
|
|
return nMeters
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert numbers to a custom locale format.
|
|
* Enables language-sensitive number formatting using locales with browser native method toLocaleString.
|
|
* This is useful to change the decimal separator as well as to add/change the thousands separator.
|
|
* @param {number|string} number
|
|
* @param {number} decimals, default is 2
|
|
* @returns {string}
|
|
* @api stable
|
|
*/
|
|
_numberFormat(number, decimals = 2) {
|
|
var locale = this.get('numberFormat')
|
|
if (!locale) return Number(number).toFixed(decimals)
|
|
return Number(number).toLocaleString(locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals })
|
|
}
|
|
}
|
|
|
|
/** Custom infos list
|
|
* @api stable
|
|
*/
|
|
ol_control_Profile.prototype.info = {
|
|
"zmin": "Zmin",
|
|
"zmax": "Zmax",
|
|
"ytitle": "Altitude", // Unit of measurement is autogenerated
|
|
"xtitle": "Distance", // Unit of measurement is autogenerated
|
|
"time": "Time",
|
|
"altitude": "Altitude",
|
|
"elevation": "Elevation gain / loss",
|
|
"elevgain": "Elevation gain",
|
|
"elevloss": "Elevation loss",
|
|
"maxslope": "Max. slope",
|
|
"avgslope": "Avg. slope",
|
|
};
|
|
|
|
// Accepted units
|
|
ol_control_Profile.prototype.Unit = {
|
|
Meter: 'm',
|
|
Kilometer: 'km',
|
|
Foot: 'ft',
|
|
Mile: 'mi'
|
|
}
|
|
// Meter divided by...
|
|
ol_control_Profile.prototype.FOOT_VALUE = 0.3048
|
|
ol_control_Profile.prototype.MILE_VALUE = 1609.344
|
|
ol_control_Profile.prototype.KILOMETER_VALUE = 1000
|
|
|
|
|
|
// For backward compatibility
|
|
// eslint-disable-next-line no-unused-vars
|
|
var ol_control_Profil = ol_control_Profile;
|
|
|
|
export default ol_control_Profile |