diff --git a/js/src/chartviewmodel.js b/js/src/chartviewmodel.js new file mode 100644 index 0000000..b9f67ea --- /dev/null +++ b/js/src/chartviewmodel.js @@ -0,0 +1,203 @@ +/* + * μlogger + * + * Copyright(C) 2019 Bartek Fabiszewski (www.fabiszewski.net) + * + * This is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +import Chartist from 'chartist' +import ViewModel from './viewmodel.js'; +import ctAxisTitle from 'chartist-plugin-axistitle'; +import { lang } from './initializer.js'; +import uUtils from './utils.js'; + +/** + * @typedef {Object} PlotPoint + * @property {number} x + * @property {number} y + */ +/** + * @typedef {PlotPoint[]} PlotData + */ + +// FIXME: Chartist is not suitable for large data sets +const LARGE_DATA = 1000; +export default class ChartViewModel extends ViewModel { + /** + * @param {uState} state + */ + constructor(state) { + super({ + pointSelected: null, + chartVisible: false, + buttonVisible: false, + onChartToggle: null + }); + this.state = state; + /** @type {PlotData} */ + this.data = []; + /** @type {?Chartist.Line} */ + this.chart = null; + /** @type {?NodeListOf} */ + this.chartPoints = null; + /** @type {HTMLDivElement} */ + this.chartElement = document.querySelector('#chart'); + /** @type {HTMLDivElement} */ + this.chartContainer = this.chartElement.parentElement; + /** @type {HTMLAnchorElement} */ + this.buttonElement = document.querySelector('#altitudes'); + } + + init() { + this.chartSetup(); + this.setObservers(); + this.bindAll(); + } + + chartSetup() { + uUtils.addCss('css/chartist.min.css', 'chartist_css'); + this.chart = new Chartist.Line(this.chartElement, { + series: [ this.data ] + }, { + lineSmooth: true, + showArea: true, + axisX: { + type: Chartist.AutoScaleAxis, + onlyInteger: true, + showLabel: false + }, + plugins: [ + ctAxisTitle({ + axisY: { + axisTitle: `${lang.strings['altitude']} (${lang.unit('unitDistance')})`, + axisClass: 'ct-axis-title', + offset: { + x: 0, + y: 11 + }, + textAnchor: 'middle', + flipTitle: true + } + }) + ] + }); + + this.chart.on('created', () => this.onCreated()); + } + + onCreated() { + if (this.data.length && this.data.length <= LARGE_DATA) { + this.chartPoints = document.querySelectorAll('.ct-series .ct-point'); + const len = this.chartPoints.length; + for (let id = 0; id < len; id++) { + this.chartPoints[id].addEventListener('click', () => { + this.model.pointSelected = id; + }); + } + } + } + + setObservers() { + this.state.onChanged('currentTrack', (track) => { + this.render(); + this.model.buttonVisible = !!track && track.hasPlotData; + }); + this.onChanged('buttonVisible', (visible) => this.renderButton(visible)); + this.onChanged('chartVisible', (visible) => this.renderContainer(visible)); + this.model.onChartToggle = () => { + this.model.chartVisible = !this.model.chartVisible; + }; + } + + /** + * @param {boolean} isVisible + */ + renderContainer(isVisible) { + if (isVisible) { + this.chartContainer.style.display = 'block'; + this.render(); + } else { + this.chartContainer.style.display = 'none'; + } + } + + /** + * @param {boolean} isVisible + */ + renderButton(isVisible) { + if (isVisible) { + this.buttonElement.style.visibility = 'visible'; + } else { + this.buttonElement.style.visibility = 'hidden'; + } + } + + render() { + let data = []; + if (this.state.currentTrack && this.state.currentTrack.hasPlotData && this.model.chartVisible) { + data = this.state.currentTrack.plotData; + } else { + this.model.chartVisible = false; + } + if (this.data !== data) { + console.log(`Chart update (${data.length})`); + this.data = data; + const options = { + lineSmooth: (data.length <= LARGE_DATA) + }; + this.chart.update({ series: [ data ] }, options, true); + } + } + + /** + * @param {number} pointId + * @param {string} $className + */ + pointAddClass(pointId, $className) { + if (this.model.chartVisible && this.chartPoints.length > pointId) { + const point = this.chartPoints[pointId]; + point.classList.add($className); + } + } + + /** + * @param {string} $className + */ + pointsRemoveClass($className) { + this.chartPoints.forEach((el) => el.classList.remove($className)); + } + + /** + * @param {number} pointId + */ + onPointOver(pointId) { + this.pointAddClass(pointId, 'ct-point-hilight'); + } + + onPointOut() { + this.pointsRemoveClass('ct-point-hilight'); + } + + /** + * @param {number} pointId + */ + onPointSelect(pointId) { + this.pointAddClass(pointId, 'ct-point-selected'); + } + + onPointUnselect() { + this.pointsRemoveClass('ct-point-selected'); + } +} diff --git a/js/test/chartviewmodel.test.js b/js/test/chartviewmodel.test.js new file mode 100644 index 0000000..b9483a9 --- /dev/null +++ b/js/test/chartviewmodel.test.js @@ -0,0 +1,407 @@ +/* + * μlogger + * + * Copyright(C) 2019 Bartek Fabiszewski (www.fabiszewski.net) + * + * This is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +import ChartViewModel from '../src/chartviewmodel.js'; +import Chartist from 'chartist' +import TrackFactory from './helpers/trackfactory.js'; +import ViewModel from '../src/viewmodel.js'; +import { lang } from '../src/initializer.js'; +import uObserve from '../src/observe.js'; +import uState from '../src/state.js'; +import uUtils from '../src/utils.js'; + +describe('ChartViewModel tests', () => { + + let state; + /** @type {HTMLAnchorElement} */ + let buttonEl; + /** @type {HTMLAnchorElement} */ + let closeEl; + /** @type {HTMLDivElement} */ + let chartEl; + /** @type {HTMLDivElement} */ + let chartContainerEl; + let vm; + let mockChart; + let chartFixture; + let chartData; + let chartPointNodes; + + beforeEach(() => { + // language=XML + chartFixture = ` + + + + + + + + + + + + + `; + const fixture = `
+
+ chart +
+
+
+ close +
+
`; + chartData = [ + { x: 0, y: 130 }, + { x: 48, y: 104 }, + { x: 92, y: 185 }, + { x: 136, y: 185 }, + { x: 236, y: 118 }, + { x: 387, y: 118 } + ]; + document.body.insertAdjacentHTML('afterbegin', fixture); + chartEl = document.querySelector('#chart'); + chartContainerEl = document.querySelector('#bottom'); + buttonEl = document.querySelector('#altitudes'); + closeEl = document.querySelector('#chart-close'); + const chartRendered = uUtils.nodeFromHtml(chartFixture); + chartPointNodes = chartRendered.querySelectorAll('.ct-series .ct-point'); + state = new uState(); + vm = new ChartViewModel(state); + spyOn(lang, 'unit'); + mockChart = jasmine.createSpyObj('mockChart', { + 'on': { /* ignored */ }, + 'update': { /* ignored */ } + }); + spyOn(Chartist, 'Line').and.returnValue(mockChart); + }); + + afterEach(() => { + document.body.removeChild(document.querySelector('#fixture')); + uObserve.unobserveAll(lang); + }); + + it('should create instance', () => { + // then + expect(vm).toBeInstanceOf(ViewModel); + expect(vm.state).toBe(state); + expect(vm.chartElement).toBe(chartEl); + expect(vm.chartContainer).toBe(chartContainerEl); + expect(vm.chart).toBe(null); + expect(vm.data).toEqual([]); + }); + + it('should initialize chart, set and bind observers', () => { + // given + spyOn(vm, 'chartSetup'); + spyOn(vm, 'setObservers'); + spyOn(vm, 'bindAll'); + // when + vm.init(); + // then + expect(vm.chartSetup).toHaveBeenCalledTimes(1); + expect(vm.setObservers).toHaveBeenCalledTimes(1); + expect(vm.bindAll).toHaveBeenCalledTimes(1); + }); + + it('should set up chart', () => { + // given + spyOn(uUtils, 'addCss'); + // when + vm.chartSetup(); + // then + expect(uUtils.addCss).toHaveBeenCalledWith('css/chartist.min.css', 'chartist_css'); + expect(Chartist.Line).toHaveBeenCalledWith(chartEl, jasmine.any(Object), jasmine.any(Object)); + expect(mockChart.on).toHaveBeenCalledWith('created', jasmine.any(Function)); + }); + + it('should add click listeners to all chart points on created', () => { + // given + chartEl.insertAdjacentHTML('afterbegin', chartFixture); + vm.data = chartData; + spyOn(EventTarget.prototype, 'addEventListener'); + // when + vm.onCreated(); + // then + expect(EventTarget.prototype.addEventListener).toHaveBeenCalledTimes(chartData.length); + expect(EventTarget.prototype.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function)); + expect(vm.chartPoints).toEqual(chartPointNodes); + }); + + it('should render chart on non-empty track and show altitudes button', () => { + // given + spyOn(vm, 'render'); + const positions = [ + TrackFactory.getPosition({ id: 1, latitude: 2, longitude: 3, altitude: 4 }), + TrackFactory.getPosition({ id: 2, latitude: 3, longitude: 4, altitude: 5 }) + ]; + state.currentTrack = null; + vm.model.buttonVisible = false; + buttonEl.style.visibility = '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'); + }); + + it('should render chart on null track and hide altitudes button', () => { + // given + spyOn(vm, 'render'); + const positions = [ + TrackFactory.getPosition({ id: 1, latitude: 2, longitude: 3, altitude: 4 }), + TrackFactory.getPosition({ id: 2, latitude: 3, longitude: 4, altitude: 5 }) + ]; + state.currentTrack = TrackFactory.getTrack(positions); + vm.model.buttonVisible = true; + buttonEl.style.visibility = 'visible'; + // when + vm.setObservers(); + state.currentTrack = null; + // then + expect(vm.render).toHaveBeenCalledTimes(1); + expect(vm.model.buttonVisible).toBe(false); + expect(buttonEl.style.visibility).toBe('hidden'); + }); + + it('should render chart on empty track and hide altitudes button', () => { + // given + spyOn(vm, 'render'); + state.currentTrack = TrackFactory.getTrack(2); + vm.model.buttonVisible = true; + buttonEl.style.visibility = 'visible'; + // 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'); + }); + + it('should render button visible', () => { + // given + vm.model.buttonVisible = false; + buttonEl.style.visibility = 'hidden'; + // when + vm.setObservers(); + vm.model.buttonVisible = true; + // then + expect(buttonEl.style.visibility).toBe('visible'); + }); + + it('should render button hidden', () => { + // given + vm.model.buttonVisible = true; + buttonEl.style.visibility = 'visible'; + // when + vm.setObservers(); + vm.model.buttonVisible = false; + // then + expect(buttonEl.style.visibility).toBe('hidden'); + }); + + it('should render chart container visible and render chart', () => { + // given + spyOn(vm, 'render'); + vm.model.chartVisible = false; + chartContainerEl.style.display = 'none'; + // when + vm.setObservers(); + vm.model.chartVisible = true; + // then + expect(vm.render).toHaveBeenCalledTimes(1); + expect(chartContainerEl.style.display).toBe('block'); + }); + + it('should render chart container hidden', () => { + // given + spyOn(vm, 'render'); + vm.model.chartVisible = true; + chartContainerEl.style.display = 'block'; + // when + vm.setObservers(); + vm.model.chartVisible = false; + // then + expect(vm.render).not.toHaveBeenCalled(); + expect(chartContainerEl.style.display).toBe('none'); + }); + + it('should render chart on non-empty track and chart visible', () => { + // given + const positions = [ + TrackFactory.getPosition({ id: 1, latitude: 2, longitude: 3, altitude: 4 }), + TrackFactory.getPosition({ id: 2, latitude: 3, longitude: 4, altitude: 5 }) + ]; + const track = TrackFactory.getTrack(positions); + state.currentTrack = track; + vm.model.chartVisible = true; + vm.data = null; + vm.chartSetup(); + // when + vm.render(); + // then + expect(mockChart.update).toHaveBeenCalledTimes(1); + expect(mockChart.update.calls.mostRecent().args[0].series[0]).toBe(track.plotData); + expect(vm.data).toBe(track.plotData); + }); + + it('should not render chart on same track and chart visible', () => { + // given + const positions = [ + TrackFactory.getPosition({ id: 1, latitude: 2, longitude: 3, altitude: 4 }), + TrackFactory.getPosition({ id: 2, latitude: 3, longitude: 4, altitude: 5 }) + ]; + const track = TrackFactory.getTrack(positions); + state.currentTrack = track; + vm.model.chartVisible = true; + vm.data = track.plotData; + vm.chartSetup(); + // when + vm.render(); + // then + expect(mockChart.update).not.toHaveBeenCalled(); + expect(vm.data).toBe(track.plotData); + }); + + it('should render empty chart on empty track and hide chart container', () => { + // given + const track = TrackFactory.getTrack(0); + state.currentTrack = track; + vm.model.chartVisible = true; + vm.data = chartData; + vm.chartSetup(); + // when + vm.render(); + // then + expect(mockChart.update).toHaveBeenCalledTimes(1); + expect(mockChart.update.calls.mostRecent().args[0].series[0]).toEqual(track.plotData); + expect(vm.data).toEqual(track.plotData); + expect(vm.model.chartVisible).toBe(false); + }); + + it('should render empty chart on null track and hide chart container', () => { + // given + state.currentTrack = null; + vm.model.chartVisible = true; + vm.data = chartData; + vm.chartSetup(); + // when + vm.render(); + // then + expect(mockChart.update).toHaveBeenCalledTimes(1); + expect(mockChart.update.calls.mostRecent().args[0].series[0]).toEqual([]); + expect(vm.data).toEqual([]); + expect(vm.model.chartVisible).toBe(false); + }); + + it('should hilight chart point', () => { + // given + vm.model.chartVisible = true; + vm.chartPoints = chartPointNodes; + const pointId = 0; + /** @type {SVGLineElement} */ + const point = chartPointNodes[pointId]; + // when + vm.onPointOver(pointId); + // then + expect(point.classList.contains('ct-point-hilight')).toBe(true); + }); + + it('should remove hilight from all points', () => { + // given + vm.model.chartVisible = true; + vm.chartPoints = chartPointNodes; + const pointId = 0; + /** @type {SVGLineElement} */ + const point = chartPointNodes[pointId]; + point.classList.add('ct-point-hilight'); + // when + vm.onPointOut(); + // then + expect(point.classList.contains('ct-point-hilight')).toBe(false); + }); + + it('should select chart point', () => { + // given + vm.model.chartVisible = true; + vm.chartPoints = chartPointNodes; + const pointId = 0; + /** @type {SVGLineElement} */ + const point = chartPointNodes[pointId]; + // when + vm.onPointSelect(pointId); + // then + expect(point.classList.contains('ct-point-selected')).toBe(true); + }); + + it('should remove selection from all points', () => { + // given + vm.model.chartVisible = true; + vm.chartPoints = chartPointNodes; + const pointId = 0; + /** @type {SVGLineElement} */ + const point = chartPointNodes[pointId]; + point.classList.add('ct-point-selected'); + // when + vm.onPointUnselect(); + // then + expect(point.classList.contains('ct-point-selected')).toBe(false); + }); + + it('should show chart on button click', () => { + // given + spyOn(vm, 'renderContainer'); + vm.model.chartVisible = false; + // when + vm.bindAll(); + vm.setObservers(); + buttonEl.click(); + // then + expect(vm.model.chartVisible).toBe(true); + }); + + it('should hide chart on button click', () => { + // given + spyOn(vm, 'renderContainer'); + vm.model.chartVisible = true; + // when + vm.bindAll(); + vm.setObservers(); + buttonEl.click(); + // then + expect(vm.model.chartVisible).toBe(false); + }); + + it('should hide chart on close click', () => { + // given + spyOn(vm, 'renderContainer'); + vm.model.chartVisible = true; + // when + vm.bindAll(); + vm.setObservers(); + closeEl.click(); + // then + expect(vm.model.chartVisible).toBe(false); + }); + +}); diff --git a/js/test/helpers/trackfactory.js b/js/test/helpers/trackfactory.js index 7d120be..3827aa3 100644 --- a/js/test/helpers/trackfactory.js +++ b/js/test/helpers/trackfactory.js @@ -27,77 +27,82 @@ export default class TrackFactory { /** * @template T - * @param {number} length + * @param {(number|uPosition[])=} p Positions array or number of positions to be autogenerated * @param {T} type * @param {Object} params * @return {T} */ - static getSet(length = 2, type, params) { + static getSet(p = 2, type, params) { let track; if (type === uTrack) { track = new uTrack(params.id, params.name, params.user); } else { track = new uPositionSet(); } - if (length) { - const positions = []; - let lat = 21.01; - let lon = 52.23; - for (let i = 0; i < length; i++) { - positions.push(this.getPosition(i + 1, lat, lon)); - lat += 0.5; - lon += 0.5; + if (Array.isArray(p)) { + track.fromJson(p, true); + } else { + const length = p; + if (length) { + const positions = []; + let lat = 21.01; + let lon = 52.23; + for (let i = 0; i < length; i++) { + positions.push(this.getPosition({ id: i + 1, latitude: lat, longitude: lon })); + lat += 0.5; + lon += 0.5; + } + track.fromJson(positions, true); } - track.fromJson(positions, true); } return track; } /** - * @param {number=} length + * @param {(number|uPosition[])=} p Positions array or number of positions to be autogenerated * @param {{ id: number, name: string, user: uUser }=} params * @return {uTrack} */ - static getTrack(length = 2, params) { + static getTrack(p = 2, params) { params = params || {}; params.id = params.id || 1; params.name = params.name || 'test track'; params.user = params.user || new uUser(1, 'testUser'); - return this.getSet(length, uTrack, params); + return this.getSet(p, uTrack, params); } /** - * @param {number} length + * @param {(number|uPosition[])=} p Positions array or number of positions to be autogenerated + * @param {uPosition[]=} positions * @return {uPositionSet} */ - static getPositionSet(length = 2) { - return this.getSet(length, uPositionSet); + static getPositionSet(p = 2, positions) { + return this.getSet(p, uPositionSet, positions); } /** - * @param {number=} id - * @param {number=} latitude - * @param {number=} longitude + * @param {Object=} params * @return {uPosition} */ - static getPosition(id = 1, latitude = 52.23, longitude = 21.01) { + static getPosition(params) { + params = params || {}; const position = new uPosition(); - position.id = id; - position.latitude = latitude; - position.longitude = longitude; - position.altitude = null; - position.speed = null; - position.bearing = null; - position.timestamp = 1; - position.accuracy = null; - position.provider = null; - position.comment = null; - position.image = null; - position.username = 'testUser'; - position.trackid = 1; - position.trackname = 'test track'; - position.meters = 0; - position.seconds = 0; + position.id = params.id || 1; + position.latitude = params.latitude || 52.23; + position.longitude = params.longitude || 21.01; + position.altitude = params.altitude || null; + position.speed = params.speed || null; + position.bearing = params.bearing || null; + position.timestamp = params.timestamp || 1; + position.accuracy = params.accuracy || null; + position.provider = params.provider || null; + position.comment = params.comment || null; + position.image = params.image || null; + position.username = params.username || 'testUser'; + position.trackid = params.trackid || 1; + position.trackname = params.trackname || 'test track'; + position.meters = params.meters || 0; + position.seconds = params.seconds || 0; return position; } }