From 5ae7753353fbb43fd330b858ac6d36aa5a0a6dc8 Mon Sep 17 00:00:00 2001 From: Bartek Fabiszewski Date: Tue, 16 Jun 2020 19:19:01 +0200 Subject: [PATCH] Feature: track color by speed, altitude; extended summary --- images/altitude.svg | 1 + images/speed.svg | 4 ++ index.php | 8 ++- js/src/chartviewmodel.js | 6 +- js/src/config.js | 1 + js/src/lang.js | 56 ++++++++------- js/src/mapapi/api_gmaps.js | 27 ++++++- js/src/mapapi/api_openlayers.js | 116 +++++++++++++++++++++++++++--- js/src/mapviewmodel.js | 106 ++++++++++++++++++++++----- js/src/position.js | 14 ++++ js/src/track.js | 31 +++++++- js/src/trackviewmodel.js | 18 ++++- js/src/utils.js | 21 ++++++ js/test/api_openlayers.test.js | 61 +++++++++++++++- js/test/chartviewmodel.test.js | 20 +++--- js/test/configdialogmodel.test.js | 2 +- js/test/lang.test.js | 20 +++--- js/test/mapviewmodel.test.js | 3 + js/test/utils.test.js | 11 +++ lang/en.php | 2 + 20 files changed, 445 insertions(+), 83 deletions(-) create mode 100644 images/altitude.svg create mode 100644 images/speed.svg diff --git a/images/altitude.svg b/images/altitude.svg new file mode 100644 index 0000000..7a7e8a7 --- /dev/null +++ b/images/altitude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/speed.svg b/images/speed.svg new file mode 100644 index 0000000..a83a1f7 --- /dev/null +++ b/images/speed.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/index.php b/index.php index b2ebcc3..4201515 100644 --- a/index.php +++ b/index.php @@ -85,8 +85,14 @@
+
+ +
+
+
+
- +
diff --git a/js/src/chartviewmodel.js b/js/src/chartviewmodel.js index c98d4b3..9d629b0 100644 --- a/js/src/chartviewmodel.js +++ b/js/src/chartviewmodel.js @@ -87,7 +87,7 @@ export default class ChartViewModel extends ViewModel { plugins: [ ctAxisTitle({ axisY: { - axisTitle: `${$._('altitude')} (${$.unit('unitDistance')})`, + axisTitle: `${$._('altitude')} (${$.unit('unitDistance')} ${$.unit('unitAltitude')})`, axisClass: 'ct-axis-title', offset: { x: 0, @@ -162,9 +162,9 @@ export default class ChartViewModel extends ViewModel { */ renderButton(isVisible) { if (isVisible) { - this.buttonElement.style.visibility = 'visible'; + this.buttonElement.classList.remove('menu-hidden'); } else { - this.buttonElement.style.visibility = 'hidden'; + this.buttonElement.classList.add('menu-hidden'); } } diff --git a/js/src/config.js b/js/src/config.js index 60d36de..3b281c0 100644 --- a/js/src/config.js +++ b/js/src/config.js @@ -103,6 +103,7 @@ export default class uConfig { this.unitDistanceMajor = 'unitkm'; } this.unitDay = 'unitday'; + this.unitAltitude = 'unitamsl'; } /** diff --git a/js/src/lang.js b/js/src/lang.js index 51d92fa..1a09b39 100644 --- a/js/src/lang.js +++ b/js/src/lang.js @@ -67,59 +67,67 @@ export default class uLang { /** * Get speed converted to locale units * @param {number} ms Speed in meters per second - * @param {boolean} withUnit - * @return {(number|string)} String when with unit + * @param {?boolean=false} withUnit + * @return {string} */ - getLocaleSpeed(ms, withUnit) { + getLocaleSpeed(ms, withUnit = false) { const value = Math.round(ms * this.config.factorSpeed * 100) / 100; + let localized = value.toLocaleString(this.config.lang); if (withUnit) { - return `${value.toLocaleString(this.config.lang)} ${this.unit('unitSpeed')}`; + localized += ` ${this.unit('unitSpeed')}`; } - return value; + return localized; } /** * Get distance converted to locale units * @param {number} m Distance in meters - * @param {boolean} withUnit - * @return {(number|string)} String when with unit + * @param {?boolean=false} withUnit + * @return {string} */ - getLocaleDistanceMajor(m, withUnit) { + getLocaleDistanceMajor(m, withUnit = false) { const value = Math.round(m * this.config.factorDistanceMajor / 10) / 100; + let localized = value.toLocaleString(this.config.lang); if (withUnit) { - return `${value.toLocaleString(this.config.lang)} ${this.unit('unitDistanceMajor')}` + localized += ` ${this.unit('unitDistanceMajor')}`; } - return value; + return localized; } /** * @param {number} m Distance in meters - * @param {boolean} withUnit - * @return {(number|string)} String when with unit + * @param {?boolean=false} withUnit + * @return {string} */ - getLocaleDistance(m, withUnit) { + getLocaleDistance(m, withUnit = false) { const value = Math.round(m * this.config.factorDistance * 100) / 100; + let localized = value.toLocaleString(this.config.lang); if (withUnit) { - return `${value.toLocaleString(this.config.lang)} ${this.unit('unitDistance')}`; + localized += ` ${this.unit('unitDistance')}`; } - return value; + return localized; } /** - * @param {number} m Distance in meters - * @param {boolean} withUnit - * @return {(number|string)} String when with unit + * @param {number} m Altitude in meters + * @param {?boolean=false} withUnit + * @return {string} */ - getLocaleAltitude(m, withUnit) { - return this.getLocaleDistance(m, withUnit); + getLocaleAltitude(m, withUnit = false) { + let localized = this.getLocaleDistance(m, withUnit); + if (withUnit) { + localized += ` ${this.unit('unitAltitude')}`; + } + // use typographic minus + return localized.replace('-', '−'); } /** - * @param {number} m Distance in meters - * @param {boolean} withUnit - * @return {(number|string)} String when with unit + * @param {number} m Accuracy in meters + * @param {?boolean=false} withUnit + * @return {string} */ - getLocaleAccuracy(m, withUnit) { + getLocaleAccuracy(m, withUnit = false) { return this.getLocaleDistance(m, withUnit); } diff --git a/js/src/mapapi/api_gmaps.js b/js/src/mapapi/api_gmaps.js index c4dd038..aa0e6bd 100644 --- a/js/src/mapapi/api_gmaps.js +++ b/js/src/mapapi/api_gmaps.js @@ -142,8 +142,10 @@ export default class GoogleMapsApi { const promise = new Promise((resolve) => { google.maps.event.addListenerOnce(this.map, 'tilesloaded', () => { console.log('tilesloaded'); - this.saveState(); - this.map.addListener('idle', this.saveState); + if (this.map) { + this.saveState(); + this.map.addListener('idle', this.saveState); + } resolve(); }) }); @@ -370,7 +372,26 @@ export default class GoogleMapsApi { // ignore for google API } - static get loadTimeoutMs() { + /** + * Set default track style + */ + // eslint-disable-next-line class-methods-use-this + setTrackDefaultStyle() { + // ignore for google API + } + + /** + * Set gradient style for given track property and scale + * @param {uTrack} track + * @param {string} property + * @param {{ minValue: number, maxValue: number, minColor: number[], maxColor: number[] }} scale + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + setTrackGradientStyle(track, property, scale) { + // ignore for google API + } + + static get loadTimeoutMs() { return 10000; } diff --git a/js/src/mapapi/api_openlayers.js b/js/src/mapapi/api_openlayers.js index 5793c29..1175ffd 100644 --- a/js/src/mapapi/api_openlayers.js +++ b/js/src/mapapi/api_openlayers.js @@ -179,17 +179,10 @@ export default class OpenLayersApi { } // add track and markers layers - const lineStyle = new ol.style.Style({ - stroke: new ol.style.Stroke({ - color: uUtils.hexToRGBA(config.strokeColor, config.strokeOpacity), - width: config.strokeWeight - }) - }); this.layerTrack = new ol.layer.VectorLayer({ name: 'Track', type: 'data', - source: new ol.source.Vector(), - style: lineStyle + source: new ol.source.Vector() }); this.layerMarkers = new ol.layer.VectorLayer({ name: 'Markers', @@ -251,6 +244,107 @@ export default class OpenLayersApi { }; } + /** + * Set default track style + */ + setTrackDefaultStyle() { + const lineStyle = new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: uUtils.hexToRGBA(config.strokeColor, config.strokeOpacity), + width: config.strokeWeight + }) + }) + this.layerTrack.setStyle(lineStyle); + } + + /** + * @param {CanvasRenderingContext2D} context + * @param {Coordinate[]} coordinates + * @param {string[]} colors + * @return {Style} + */ + getGradientStyle(context, coordinates, colors) { + const pixelStart = this.map.getPixelFromCoordinate(coordinates[0]); + const pixelEnd = this.map.getPixelFromCoordinate(coordinates[1]); + const scale = window.devicePixelRatio; + const x0 = pixelStart[0] * scale; + const y0 = pixelStart[1] * scale; + const x1 = pixelEnd[0] * scale; + const y1 = pixelEnd[1] * scale; + const gradient = context.createLinearGradient(x0, y0, x1, y1); + gradient.addColorStop(0, colors[0]); + gradient.addColorStop(1, colors[1]); + return new ol.style.Style({ + geometry: new ol.geom.LineString(coordinates), + stroke: new ol.style.Stroke({ + color: gradient, + width: config.strokeWeight * 2 + }) + }) + } + + /** + * Set gradient style for given track property and scale + * @param {uTrack} track + * @param {string} property + * @param {{ minValue: number, maxValue: number, minColor: number[], maxColor: number[] }} scale + */ + setTrackGradientStyle(track, property, scale) { + const minValue = scale.minValue; + const maxValue = scale.maxValue; + const minColor = scale.minColor; + const maxColor = scale.maxColor; + if (track.length < 2 || maxValue < minValue) { + this.setTrackDefaultStyle(); + return; + } + const canvas = document.createElement('canvas', { alpha: false, desynchronized: true }); + const ctx = canvas.getContext('2d'); + /** + * @param {Feature} feature + * @return {Style[]} + */ + const lineStyle = (feature) => { + const styles = [ + new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: 'grey', + width: config.strokeWeight * 2 + 2 + }) + }) + ]; + const geometry = feature.getGeometry(); + if (minValue === maxValue) { + styles.push(new ol.style.Style({ + geometry: geometry, + stroke: new ol.style.Stroke({ + color: uUtils.getScaleColor(minColor, maxColor, 0.5), + width: config.strokeWeight * 2 + }) + })); + return styles; + } + let position = track.positions[0]; + const value = position[property] !== null ? position[property] : 0; + let colorStart = uUtils.getScaleColor(minColor, maxColor, (value - minValue) / (maxValue - minValue)); + let index = 1; + geometry.forEachSegment((start, end) => { + position = track.positions[index]; + let colorStop; + if (position[property] !== null) { + colorStop = uUtils.getScaleColor(minColor, maxColor, (position[property] - minValue) / (maxValue - minValue)); + } else { + colorStop = colorStart; + } + styles.push(this.getGradientStyle(ctx, [ start, end ], [ colorStart, colorStop ])); + colorStart = colorStop; + index++; + }); + return styles; + }; + this.layerTrack.setStyle(lineStyle); + } + initPopups() { const popupContainer = document.createElement('div'); popupContainer.id = 'popup-container'; @@ -438,8 +532,10 @@ export default class OpenLayersApi { const promise = new Promise((resolve) => { this.map.once('rendercomplete', () => { console.log('rendercomplete'); - this.saveState(); - this.map.on('moveend', this.saveState); + if (this.map) { + this.saveState(); + this.map.on('moveend', this.saveState); + } resolve(); }); }); diff --git a/js/src/mapviewmodel.js b/js/src/mapviewmodel.js index e178d34..1a7e74c 100644 --- a/js/src/mapviewmodel.js +++ b/js/src/mapviewmodel.js @@ -32,19 +32,20 @@ import uUtils from './utils.js'; * @interface * @memberOf MapViewModel * @type {Object} - * @property {function(MapViewModel)} init - * @property {function} cleanup - * @property {function(uTrack, boolean)} displayTrack - * @property {function} clearMap * @property {function(number)} animateMarker + * @property {function(uTrack, boolean)} displayTrack + * @property {function} cleanup + * @property {function} clearMap * @property {function} getBounds - * @property {function} zoomToExtent - * @property {function} zoomToBounds + * @property {function(MapViewModel)} init + * @property {function} setTrackDefaultStyle + * @property {function(uTrack, string, Object)} setTrackGradientStyle * @property {function} updateSize * @property {function} updateState + * @property {function} zoomToBounds + * @property {function} zoomToExtent */ - /** * @typedef {Object} MapParams * @property {number[]} center @@ -66,12 +67,19 @@ export default class MapViewModel extends ViewModel { /** @type {?number} */ markerSelect: null, // click handler - onMenuToggle: null + onMenuToggle: null, + speedVisible: false, + altitudeVisible: false }); - this.model.onMenuToggle = () => this.onMapResize(); this.state = state; /** @type HTMLElement */ this.mapElement = document.querySelector('#map-canvas'); + /** @type HTMLInputElement */ + this.speedEl = this.getBoundElement('speedVisible'); + /** @type HTMLInputElement */ + this.altitudeEl = this.getBoundElement('altitudeVisible'); + /** @type HTMLElement */ + this.styleEl = this.getBoundElement('trackColor'); this.savedBounds = null; this.api = null; } @@ -138,6 +146,8 @@ export default class MapViewModel extends ViewModel { setObservers() { config.onChanged('mapApi', (mapApi) => { this.loadMapAPI(mapApi); + this.toggleStyleOptions(); + this.toggleStyleMenu(); }); this.state.onChanged('currentTrack', (track) => { if (!this.api) { @@ -151,6 +161,7 @@ export default class MapViewModel extends ViewModel { }); this.displayTrack(track, true); } + this.toggleStyleOptions(); }); this.state.onChanged('history', () => { const history = this.state.history; @@ -167,6 +178,19 @@ export default class MapViewModel extends ViewModel { } } }); + this.model.onMenuToggle = () => this.onMapResize(); + this.onChanged('speedVisible', (visible) => { + if (visible) { + this.model.altitudeVisible = false; + } + this.setTrackStyle(); + }); + this.onChanged('altitudeVisible', (visible) => { + if (visible) { + this.model.speedVisible = false; + } + this.setTrackStyle(); + }); } /** @@ -180,10 +204,67 @@ export default class MapViewModel extends ViewModel { update = false; } this.state.history = null; + this.setTrackStyle(); this.api.displayTrack(track, update) .finally(() => this.state.jobStop()); } + onMapResize() { + if (this.api) { + this.api.updateSize(); + } + } + + toggleStyleOptions() { + const track = this.state.currentTrack; + this.speedEl.disabled = !track || !track.hasSpeeds || track.length <= 1; + this.altitudeEl.disabled = !track || !track.hasAltitudes || track.length <= 1; + } + + toggleStyleMenu() { + if (config.mapApi === 'openlayers') { + this.styleEl.style.display = 'block'; + } else { + this.styleEl.style.display = 'none'; + } + } + + setTrackStyle() { + const track = this.state.currentTrack; + if (!this.api || !track) { + return; + } + if (this.model.speedVisible && track.hasSpeeds) { + this.setSpeedStyle(); + } else if (this.model.altitudeVisible && track.hasAltitudes) { + this.setAltitudeStyle(); + } else { + this.api.setTrackDefaultStyle(); + } + } + + setSpeedStyle() { + const track = this.state.currentTrack; + const scale = { + minValue: 0, + maxValue: track.maxSpeed, + minColor: [ 0, 255, 0 ], + maxColor: [ 255, 0, 0 ] + }; + this.api.setTrackGradientStyle(track, 'speed', scale); + } + + setAltitudeStyle() { + const track = this.state.currentTrack; + const scale = { + minValue: track.minAltitude, + maxValue: track.maxAltitude, + minColor: [ 0, 255, 0 ], + maxColor: [ 255, 0, 0 ] + }; + this.api.setTrackGradientStyle(track, 'altitude', scale); + } + /** * Get popup html * @param {number} id Position index @@ -302,11 +383,4 @@ export default class MapViewModel extends ViewModel { ${isExtra ? MapViewModel.getMarkerExtra(isLarge) : ''}`; return `data:image/svg+xml,${encodeURIComponent(svg)}`; } - - onMapResize() { - if (this.api) { - this.api.updateSize(); - } - } - } diff --git a/js/src/position.js b/js/src/position.js index 68e3ddd..f71cbc4 100644 --- a/js/src/position.js +++ b/js/src/position.js @@ -85,6 +85,20 @@ export default class uPosition { return (this.image != null && this.image.length > 0); } + /** + * @return {boolean} + */ + hasSpeed() { + return this.speed != null; + } + + /** + * @return {boolean} + */ + hasAltitude() { + return this.altitude != null; + } + /** * @return {?string} */ diff --git a/js/src/track.js b/js/src/track.js index d8ea507..607ccd7 100644 --- a/js/src/track.js +++ b/js/src/track.js @@ -49,6 +49,9 @@ export default class uTrack extends uPositionSet { this.user = user; this.plotData = []; this.maxId = 0; + this.maxSpeed = 0; + this.maxAltitude = null; + this.minAltitude = null; this.totalMeters = 0; this.totalSeconds = 0; this.listItem(id, name); @@ -66,6 +69,9 @@ export default class uTrack extends uPositionSet { clearTrackCounters() { this.maxId = 0; + this.maxSpeed = 0; + this.maxAltitude = null; + this.minAltitude = null; this.plotData.length = 0; this.totalMeters = 0; this.totalSeconds = 0; @@ -86,6 +92,20 @@ export default class uTrack extends uPositionSet { return this.plotData.length > 0; } + /** + * @return {boolean} + */ + get hasAltitudes() { + return this.maxAltitude !== null; + } + + /** + * @return {boolean} + */ + get hasSpeeds() { + return this.maxSpeed > 0; + } + /** * Get track data from json * @param {Object[]} posArr Positions data @@ -268,11 +288,20 @@ export default class uTrack extends uPositionSet { this.totalSeconds += position.seconds; position.totalMeters = this.totalMeters; position.totalSeconds = this.totalSeconds; - if (position.altitude != null) { + if (position.hasAltitude()) { this.plotData.push({ x: position.totalMeters, y: position.altitude }); + if (this.maxAltitude === null || position.altitude > this.maxAltitude) { + this.maxAltitude = position.altitude; + } + if (this.minAltitude === null || position.altitude < this.minAltitude) { + this.minAltitude = position.altitude; + } } if (position.id > this.maxId) { this.maxId = position.id; } + if (position.hasSpeed() && position.speed > this.maxSpeed) { + this.maxSpeed = position.speed; + } } } diff --git a/js/src/trackviewmodel.js b/js/src/trackviewmodel.js index 61b34ac..57fa35d 100644 --- a/js/src/trackviewmodel.js +++ b/js/src/trackviewmodel.js @@ -345,11 +345,12 @@ export default class TrackViewModel extends ViewModel { } renderSummary() { - if (!this.state.currentTrack || !this.state.currentTrack.hasPositions) { + const track = this.state.currentTrack; + if (!track || !track.hasPositions) { this.model.summary = ''; return; } - const last = this.state.currentTrack.positions[this.state.currentTrack.length - 1]; + const last = track.positions[track.length - 1]; if (this.state.showLatest) { const today = new Date(); @@ -362,10 +363,21 @@ export default class TrackViewModel extends ViewModel { ${dateString} ${timeString}`; } else { - this.model.summary = ` + let summary = `
${$._('tdistance')} ${$.getLocaleDistanceMajor(last.totalMeters, true)}
${$._('ttime')} ${$.getLocaleDuration(last.totalSeconds)}
`; + if (track.hasSpeeds) { + summary += `
${$._('speed')} ${$.getLocaleSpeed(track.maxSpeed, true)}
`; + } + if (track.hasAltitudes) { + let altitudes = `${$.getLocaleAltitude(track.maxAltitude, true)}`; + if (track.minAltitude !== track.maxAltitude) { + altitudes = `${$.getLocaleAltitude(track.minAltitude)}–${altitudes}`; + } + summary += `
${$._('altitude')} ${altitudes}
`; + } + this.model.summary = summary; } } diff --git a/js/src/utils.js b/js/src/utils.js index 694469a..c641cb3 100644 --- a/js/src/utils.js +++ b/js/src/utils.js @@ -147,6 +147,27 @@ export default class uUtils { .concat(opacity).join(',')})`; } + /** + * Get rgb color for given scale and intensity + * @param {number[]} start Minimum scale color as [ r, g, b ] + * @param {number[]} end Maximum scale color as [ r, g, b ] + * @param {number} intensity Intensity from 0 to 1 + * @return {string} Color as rgb() string + */ + static getScaleColor(start, end, intensity) { + if (intensity < 0 || intensity > 1) { + throw new Error('Invalid value'); + } + const rgb = []; + for (let i = 0; i < 3; i++) { + if (start[i] < 0 || start[i] > 255 || end[i] < 0 || end[i] > 255) { + throw new Error('Invalid value'); + } + rgb[i] = Math.round((end[i] - start[i]) * intensity + start[i]); + } + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + } + /** * Add link tag with type css * @param {string} url attribute diff --git a/js/test/api_openlayers.test.js b/js/test/api_openlayers.test.js index ef2ac0a..35aca89 100644 --- a/js/test/api_openlayers.test.js +++ b/js/test/api_openlayers.test.js @@ -112,8 +112,6 @@ describe('Openlayers map API tests', () => { expect(_layer.getVisible()).toBe(true); expect(_layer).toEqual(jasmine.any(ol.layer.VectorLayer)); expect(_layer.getSource()).toEqual(jasmine.any(ol.source.Vector)); - expect(_layer.getStyle().getStroke().getColor()).toBe(uUtils.hexToRGBA(config.strokeColor, config.strokeOpacity)); - expect(_layer.getStyle().getStroke().getWidth()).toBe(config.strokeWeight); expect(_layer.get('type')).toBe('data'); expect(api.layerTrack).toEqual(_layer); break; @@ -570,4 +568,63 @@ describe('Openlayers map API tests', () => { expect(api.popup.getElement().firstElementChild.innerHTML).toBe(''); expect(mockViewModel.model.markerSelect).toBe(null); }); + + it('should create gradient style', () => { + // given + const ctx = document.createElement('canvas').getContext('2d'); + const coordinates = [ [ 0, 0 ], [ 1, 1 ] ]; + const colors = [ 'white', 'red' ]; + api.map = mockMap; + spyOn(api.map, 'getPixelFromCoordinate').and.callFake((coord) => coord); + // when + const style = api.getGradientStyle(ctx, coordinates, colors); + // then + expect(style).toBeInstanceOf(ol.style.Style); + expect(style.getGeometry().getCoordinates()).toEqual(coordinates); + expect(style.getStroke().getColor()).toBeInstanceOf(CanvasGradient); + }); + + it('should set default style for track', () => { + // given + api.layerTrack = new ol.layer.VectorLayer(); + config.strokeWeight = 1234; + config.strokeColor = 'test color'; + config.strokeOpacity = 0.1234; + const color = 'rgba(1, 1, 1, 1)'; + spyOn(uUtils, 'hexToRGBA').and.returnValue(color); + // when + api.setTrackDefaultStyle(); + // then + expect(api.layerTrack.getStyle()).toBeInstanceOf(ol.style.Style); + expect(api.layerTrack.getStyle().getStroke().getWidth()).toBe(config.strokeWeight); + expect(api.layerTrack.getStyle().getStroke().getColor()).toBe(color); + expect(uUtils.hexToRGBA).toHaveBeenCalledWith(config.strokeColor, config.strokeOpacity); + }); + + it('should set gradient style for track', () => { + // given + const track = TrackFactory.getTrack(3); + track.positions[0].speed = 0; + track.positions[1].speed = 1; + track.positions[2].speed = 2; + api.map = mockMap; + api.layerTrack = new ol.layer.VectorLayer({ + source: new ol.source.Vector() + }); + spyOn(uUtils, 'getScaleColor').and.returnValue('test color'); + spyOn(api, 'getGradientStyle').and.returnValue(new ol.style.Style()); + const lineFeature = new ol.Feature({ geometry: new ol.geom.LineString([]) }); + for (let i = 0; i < track.length; i++) { + lineFeature.getGeometry().appendCoordinate(ol.proj.fromLonLat([ 0, 0 ])); + } + api.layerTrack.getSource().addFeature(lineFeature); + // when + api.setTrackGradientStyle(track, 'speed', { minValue: 0, maxValue: 2, minColor: [ 255, 255, 255 ], maxColor: [ 0, 0, 0 ] }); + api.layerTrack.getStyle()(lineFeature); + // then + expect(api.layerTrack.getStyle()).toBeInstanceOf(Function); + expect(uUtils.getScaleColor).toHaveBeenCalledTimes(track.length); + expect(api.getGradientStyle).toHaveBeenCalledTimes(track.length - 1); + }); + }); diff --git a/js/test/chartviewmodel.test.js b/js/test/chartviewmodel.test.js index ad9a307..e9828aa 100644 --- a/js/test/chartviewmodel.test.js +++ b/js/test/chartviewmodel.test.js @@ -152,14 +152,14 @@ describe('ChartViewModel tests', () => { ]; state.currentTrack = null; vm.model.buttonVisible = false; - buttonEl.style.visibility = 'hidden'; + buttonEl.classList.add('menu-hidden'); // when vm.setObservers(); state.currentTrack = TrackFactory.getTrack(positions); // then expect(vm.render).toHaveBeenCalledTimes(1); expect(vm.model.buttonVisible).toBe(true); - expect(buttonEl.style.visibility).toBe('visible'); + expect(buttonEl.classList.contains('menu-hidden')).toBeFalse(); }); it('should render chart on null track and hide altitudes button', () => { @@ -171,14 +171,14 @@ describe('ChartViewModel tests', () => { ]; state.currentTrack = TrackFactory.getTrack(positions); vm.model.buttonVisible = true; - buttonEl.style.visibility = 'visible'; + buttonEl.classList.remove('menu-hidden'); // when vm.setObservers(); state.currentTrack = null; // then expect(vm.render).toHaveBeenCalledTimes(1); expect(vm.model.buttonVisible).toBe(false); - expect(buttonEl.style.visibility).toBe('hidden'); + expect(buttonEl.classList.contains('menu-hidden')).toBeTrue(); }); it('should render chart on empty track and hide altitudes button', () => { @@ -186,36 +186,36 @@ describe('ChartViewModel tests', () => { spyOn(vm, 'render'); state.currentTrack = TrackFactory.getTrack(2); vm.model.buttonVisible = true; - buttonEl.style.visibility = 'visible'; + buttonEl.classList.remove('menu-hidden'); // when vm.setObservers(); state.currentTrack = TrackFactory.getTrack(0); // then expect(vm.render).toHaveBeenCalledTimes(1); expect(vm.model.buttonVisible).toBe(false); - expect(buttonEl.style.visibility).toBe('hidden'); + expect(buttonEl.classList.contains('menu-hidden')).toBeTrue(); }); it('should render button visible', () => { // given vm.model.buttonVisible = false; - buttonEl.style.visibility = 'hidden'; + buttonEl.classList.add('menu-hidden'); // when vm.setObservers(); vm.model.buttonVisible = true; // then - expect(buttonEl.style.visibility).toBe('visible'); + expect(buttonEl.classList.contains('menu-hidden')).toBeFalse(); }); it('should render button hidden', () => { // given vm.model.buttonVisible = true; - buttonEl.style.visibility = 'visible'; + buttonEl.classList.remove('menu-hidden'); // when vm.setObservers(); vm.model.buttonVisible = false; // then - expect(buttonEl.style.visibility).toBe('hidden'); + expect(buttonEl.classList.contains('menu-hidden')).toBeTrue(); }); it('should render chart container visible and render chart', () => { diff --git a/js/test/configdialogmodel.test.js b/js/test/configdialogmodel.test.js index 01d959e..de912e0 100644 --- a/js/test/configdialogmodel.test.js +++ b/js/test/configdialogmodel.test.js @@ -153,8 +153,8 @@ describe('ConfigDialogModel tests', () => { // given spyOn(cm, 'validate').and.returnValue(true); spyOn(config, 'save').and.returnValue(Promise.resolve()); - cm.model.layerId = '1'; cm.init(); + cm.model.layerId = '1'; const button = cm.dialog.element.querySelector("[data-bind='onSave']"); // when button.click(); diff --git a/js/test/lang.test.js b/js/test/lang.test.js index 7bd576e..7ca9695 100644 --- a/js/test/lang.test.js +++ b/js/test/lang.test.js @@ -37,7 +37,8 @@ describe('Lang tests', () => { unitDistance: 'unitd', factorDistanceMajor: 0.55, unitDistanceMajor: 'unitdm', - unitDay: 'unitday' + unitDay: 'unitday', + unitAltitude: 'unitamsl' }; mockStrings = { string1: 'łańcuch1', @@ -45,7 +46,8 @@ describe('Lang tests', () => { units: 'jp', unitd: 'jo', unitdm: 'jo / 1000', - unitday: 'd' + unitday: 'd', + unitamsl: 'a.s.l.' } }); @@ -96,7 +98,7 @@ describe('Lang tests', () => { // when lang.init(mockConfig, mockStrings); // then - expect(lang.getLocaleSpeed(value, false)).toBe(330); + expect(lang.getLocaleSpeed(value, false)).toBe('330'); }); it('should return localized speed value with unit', () => { @@ -110,7 +112,7 @@ describe('Lang tests', () => { // when lang.init(mockConfig, mockStrings); // then - expect(lang.getLocaleDistanceMajor(value, false)).toBe(0.55); + expect(lang.getLocaleDistanceMajor(value, false)).toBe('0.55'); }); it('should return localized distance major value with unit', () => { @@ -124,7 +126,7 @@ describe('Lang tests', () => { // when lang.init(mockConfig, mockStrings); // then - expect(lang.getLocaleDistance(value, false)).toBe(1300); + expect(lang.getLocaleDistance(value, false)).toBe('1,300'); }); it('should return localized distance value with unit', () => { @@ -138,28 +140,28 @@ describe('Lang tests', () => { // when lang.init(mockConfig, mockStrings); // then - expect(lang.getLocaleDistance(value, false)).toBe(1300); + expect(lang.getLocaleAltitude(value, false)).toBe('1,300'); }); it('should return localized altitude value with unit', () => { // when lang.init(mockConfig, mockStrings); // then - expect(lang.getLocaleDistance(value, true)).toBe(`1,300 ${mockStrings.unitd}`); + expect(lang.getLocaleAltitude(value, true)).toBe(`1,300 ${mockStrings.unitd} ${mockStrings.unitamsl}`); }); it('should return localized accuracy value', () => { // when lang.init(mockConfig, mockStrings); // then - expect(lang.getLocaleDistance(value, false)).toBe(1300); + expect(lang.getLocaleAccuracy(value, false)).toBe('1,300'); }); it('should return localized accuracy value with unit', () => { // when lang.init(mockConfig, mockStrings); // then - expect(lang.getLocaleDistance(value, true)).toBe(`1,300 ${mockStrings.unitd}`); + expect(lang.getLocaleAccuracy(value, true)).toBe(`1,300 ${mockStrings.unitd}`); }); it('should return localized time duration', () => { diff --git a/js/test/mapviewmodel.test.js b/js/test/mapviewmodel.test.js index bba25fa..ea18a68 100644 --- a/js/test/mapviewmodel.test.js +++ b/js/test/mapviewmodel.test.js @@ -57,6 +57,8 @@ describe('MapViewModel tests', () => { 'zoomToBounds': { /* ignored */ }, 'zoomToExtent': { /* ignored */ }, 'displayTrack': Promise.resolve(), + 'setTrackDefaultStyle': { /* ignored */ }, + 'setTrackGradientStyle': { /* ignored */ }, 'clearMap': { /* ignored */ }, 'updateSize': { /* ignored */ } }); @@ -206,6 +208,7 @@ describe('MapViewModel tests', () => { // given vm.api = mockApi; vm.bindAll(); + vm.setObservers(); // when menuButtonEl.click(); // then diff --git a/js/test/utils.test.js b/js/test/utils.test.js index 1fea76c..dc1a5c8 100644 --- a/js/test/utils.test.js +++ b/js/test/utils.test.js @@ -366,4 +366,15 @@ describe('Utils tests', () => { expect(result).toBeFalse(); }); + it('should create color from scale and intensity', () => { + // given + const start = [ 0, 128, 255 ]; + const stop = [ 255, 128, 0 ]; + const intensity = 0.5; + // when + const color = uUtils.getScaleColor(start, stop, intensity); + // then + expect(color).toBe('rgb(128, 128, 128)'); + }); + }); diff --git a/lang/en.php b/lang/en.php index ed6b78d..5ebb67d 100644 --- a/lang/en.php +++ b/lang/en.php @@ -137,6 +137,7 @@ $lang["allusers"] = "All users"; $lang["unitday"] = "d"; // abbreviation for days, like 4 d 11:11:11 $lang["unitkmh"] = "km/h"; // kilometer per hour $lang["unitm"] = "m"; // meter +$lang["unitamsl"] = "a.s.l."; // above mean see level $lang["unitkm"] = "km"; // kilometer $lang["unitmph"] = "mph"; // mile per hour $lang["unitft"] = "ft"; // feet @@ -169,4 +170,5 @@ $lang["add"] = "Add"; $lang["edit"] = "Edit"; $lang["delete"] = "Delete"; $lang["settings"] = "Settings"; +$lang["trackcolor"] = "Track color"; ?>