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 = ``;
+ 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;
}
}