From baf39cf81834cc166b6a3340704afdc57d05191e Mon Sep 17 00:00:00 2001 From: Bartek Fabiszewski Date: Tue, 17 Dec 2019 22:19:26 +0100 Subject: [PATCH] Add map view model class --- js/src/mapviewmodel.js | 210 ++++++++++++++++++++++++ js/test/mapviewmodel.test.js | 298 +++++++++++++++++++++++++++++++++++ 2 files changed, 508 insertions(+) create mode 100644 js/src/mapviewmodel.js create mode 100644 js/test/mapviewmodel.test.js diff --git a/js/src/mapviewmodel.js b/js/src/mapviewmodel.js new file mode 100644 index 0000000..4cde5bb --- /dev/null +++ b/js/src/mapviewmodel.js @@ -0,0 +1,210 @@ +/* + * μ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 { config, lang } from './initializer.js'; +import GoogleMapsApi from './mapapi/api_gmaps.js'; +import OpenLayersApi from './mapapi/api_openlayers.js'; +import ViewModel from './viewmodel.js'; +import uObserve from './observe.js'; +import uUtils from './utils.js'; + +/** + * @typedef {Object} MapViewModel.api + * @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} getBounds + * @property {function} zoomToExtent + * @property {function} zoomToBounds + * @property {function} updateSize + */ + +/** + * @class MapViewModel + */ +export default class MapViewModel extends ViewModel { + /** + * @param {uState} state + */ + constructor(state) { + super({ + /** @type {?number} */ + markerOver: null, + /** @type {?number} */ + markerSelect: null + }); + this.state = state; + /** @type HTMLElement */ + this.mapElement = document.querySelector('#map-canvas'); + this.savedBounds = null; + this.api = null; + } + + /** + * Dynamic change of map api + * @param {string} apiName API name + */ + loadMapAPI(apiName) { + if (this.api) { + try { + this.savedBounds = this.api.getBounds(); + } catch (e) { + this.savedBounds = null; + } + this.api.cleanup(); + } + this.api = this.getApi(apiName); + this.api.init() + .then(() => this.onReady()) + .catch((e) => { + let txt = uUtils.sprintf(lang.strings['apifailure'], apiName); + if (e && e.message) { + txt += ` (${e.message})`; + } + uUtils.error(e, txt); + config.mapApi = (apiName === 'gmaps') ? 'openlayers' : 'gmaps'; + }); + } + + /** + * @param {string} apiName + * @return {OpenLayersApi|GoogleMapsApi} + */ + getApi(apiName) { + return apiName === 'gmaps' ? new GoogleMapsApi(this) : new OpenLayersApi(this); + } + + onReady() { + if (this.savedBounds) { + this.api.zoomToBounds(this.savedBounds); + } + if (this.state.currentTrack) { + this.api.displayTrack(this.state.currentTrack, this.savedBounds === null); + } + config.onChanged('mapApi', (mapApi) => { + this.loadMapAPI(mapApi); + }); + this.state.onChanged('currentTrack', (track) => { + this.api.clearMap(); + if (track) { + uObserve.observe(track, 'positions', () => { + this.api.displayTrack(track, false); + this.api.zoomToExtent(); + }); + this.api.displayTrack(track, true); + } + }); + } + + /** + * Get popup html + * @param {number} id Position ID + * @returns {string} + */ + getPopupHtml(id) { + const pos = this.state.currentTrack.positions[id]; + const count = this.state.currentTrack.length; + let date = '–––'; + let time = '–––'; + if (pos.timestamp > 0) { + const dateTime = uUtils.getTimeString(new Date(pos.timestamp * 1000)); + date = dateTime.date; + time = `${dateTime.time}${dateTime.zone}`; + } + let provider = ''; + if (pos.provider === 'gps') { + provider = ` (${lang.strings['gps']})`; + } else if (pos.provider === 'network') { + provider = ` (${lang.strings['network']})`; + } + let stats = ''; + if (!this.state.showLatest) { + stats = + `
+ ${lang.strings['track']}
+ ${lang.strings['ttime']} ${lang.getLocaleDuration(pos.totalSeconds)}
+ ${lang.strings['aspeed']} ${lang.getLocaleSpeed(pos.totalSpeed, true)}
+ ${lang.strings['tdistance']} ${lang.getLocaleDistanceMajor(pos.totalMeters, true)}
+
`; + } + return ``; + } + + /** + * Get SVG marker path + * @param {boolean} isLarge Large marker with hole if true + * @return {string} + */ + static getMarkerPath(isLarge) { + const markerHole = 'M15,34.911c0,0,0.359-3.922,1.807-8.588c0.414-1.337,1.011-2.587,2.495-4.159' + + 'c1.152-1.223,3.073-2.393,3.909-4.447c1.681-6.306-3.676-9.258-8.211-9.258c-4.536,0-9.893,2.952-8.211,9.258' + + 'c0.836,2.055,2.756,3.225,3.91,4.447c1.484,1.572,2.08,2.822,2.495,4.159C14.64,30.989,15,34.911,15,34.911z M18,15.922' + + 'c0,1.705-1.342,3.087-2.999,3.087c-1.657,0-3-1.382-3-3.087c0-1.704,1.343-3.086,3-3.086C16.658,12.836,18,14.218,18,15.922z'; + const marker = 'M14.999,34.911c0,0,0.232-1.275,1.162-4.848c0.268-1.023,0.652-1.98,1.605-3.184' + + 'c0.742-0.937,1.975-1.832,2.514-3.404c1.082-4.828-2.363-7.088-5.281-7.088c-2.915,0-6.361,2.26-5.278,7.088' + + 'c0.538,1.572,1.771,2.468,2.514,3.404c0.953,1.203,1.337,2.16,1.604,3.184C14.77,33.635,14.999,34.911,14.999,34.911z'; + return isLarge ? markerHole : marker; + } + + /** + * Get marker extra mark + * @param {boolean} isLarge + * @return {string} + */ + static getMarkerExtra(isLarge) { + const offset1 = isLarge ? 'M26.074,13.517' : 'M23.328,20.715'; + const offset2 = isLarge ? 'M28.232,10.942' : 'M25.486,18.141'; + return ` + `; + } + + /** + * Get inline SVG source + * @param {string} fill + * @param {boolean=} isLarge + * @param {boolean=} isExtra + * @return {string} + */ + static getSvgSrc(fill, isLarge, isExtra) { + const svg = ` + ${isExtra ? MapViewModel.getMarkerExtra(isLarge) : ''}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; + } +} diff --git a/js/test/mapviewmodel.test.js b/js/test/mapviewmodel.test.js new file mode 100644 index 0000000..9c58ab7 --- /dev/null +++ b/js/test/mapviewmodel.test.js @@ -0,0 +1,298 @@ +/* + * μ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 { config, lang } from '../src/initializer.js'; +import MapViewModel from '../src/mapviewmodel.js'; +import TrackFactory from './helpers/trackfactory.js'; +import ViewModel from '../src/viewmodel.js'; +import uObserve from '../src/observe.js'; +import uState from '../src/state.js'; +import uUtils from '../src/utils.js'; + +describe('MapViewModel tests', () => { + + let vm; + let state; + let mapEl; + let mockApi; + let bounds; + let track; + const defaultApi = 'mockApi'; + + beforeEach(() => { + const fixture = `
+
+
`; + document.body.insertAdjacentHTML('afterbegin', fixture); + mapEl = document.querySelector('#map-canvas'); + config.reinitialize(); + config.mapApi = defaultApi; + lang.init(config); + lang.strings['apifailure'] = 'api failure: %s'; + mockApi = jasmine.createSpyObj('mockApi', { + 'init': Promise.resolve(), + 'getBounds': { /* ignored */ }, + 'cleanup': { /* ignored */ }, + 'zoomToBounds': { /* ignored */ }, + 'zoomToExtent': { /* ignored */ }, + 'displayTrack': { /* ignored */ }, + 'clearMap': { /* ignored */ } + }); + state = new uState(); + vm = new MapViewModel(state); + spyOn(vm, 'getApi').and.returnValue(mockApi); + bounds = [ 1, 2, 3, 4 ]; + track = TrackFactory.getTrack(0); + }); + + afterEach(() => { + document.body.removeChild(document.querySelector('#fixture')); + }); + + it('should create instance', () => { + // then + expect(vm).toBeInstanceOf(ViewModel); + expect(vm.state).toBe(state); + expect(vm.mapElement).toBe(mapEl); + expect(vm.api).toBe(null); + }); + + it('should load openlayers api and call onReady', (done) => { + // given + spyOn(vm, 'onReady'); + // when + vm.loadMapAPI('openlayers'); + // then + setTimeout(() => { + expect(vm.getApi).toHaveBeenCalledWith('openlayers'); + expect(vm.onReady).toHaveBeenCalledTimes(1); + done(); + }, 100); + + }); + + it('should load gmaps api and fail with error, config map api should be set to another api', (done) => { + // given + spyOn(vm, 'onReady'); + spyOn(uUtils, 'error'); + mockApi.init.and.returnValue(Promise.reject(new Error('init failed'))); + // when + vm.loadMapAPI('gmaps'); + // then + setTimeout(() => { + expect(vm.getApi).toHaveBeenCalledWith('gmaps'); + expect(vm.onReady).not.toHaveBeenCalled(); + expect(config.mapApi).toBe('openlayers'); + expect(uUtils.error).toHaveBeenCalledWith(jasmine.any(Error), jasmine.stringMatching('init failed')); + done(); + }, 100); + }); + + it('should replace map api, get bounds from map and clean up previous api', (done) => { + // given + spyOn(vm, 'onReady'); + vm.api = mockApi; + // when + vm.loadMapAPI('gmaps'); + // then + setTimeout(() => { + expect(mockApi.getBounds).toHaveBeenCalledTimes(1); + expect(mockApi.cleanup).toHaveBeenCalledTimes(1); + done(); + }, 100); + }); + + it('should zoom to bounds if has saved bounds', () => { + // given + vm.api = mockApi; + vm.savedBounds = bounds; + // when + vm.onReady(); + // then + expect(mockApi.zoomToBounds).toHaveBeenCalledTimes(1); + expect(mockApi.zoomToBounds).toHaveBeenCalledWith(bounds); + }); + + it('should not zoom to bounds if there are no saved bounds', () => { + // given + vm.api = mockApi; + vm.savedBounds = null; + // when + vm.onReady(); + // then + expect(mockApi.zoomToBounds).not.toHaveBeenCalled(); + }); + + it('should display track with update if current track is set in state and bounds are not set', () => { + // given + vm.api = mockApi; + state.currentTrack = track; + vm.savedBounds = null; + // when + vm.onReady(); + // then + expect(mockApi.displayTrack).toHaveBeenCalledTimes(1); + expect(mockApi.displayTrack).toHaveBeenCalledWith(track, true); + }); + + it('should display track without update if current track is set in state and bounds are set', () => { + // given + vm.api = mockApi; + state.currentTrack = track; + vm.savedBounds = bounds; + // when + vm.onReady(); + // then + expect(mockApi.displayTrack).toHaveBeenCalledTimes(1); + expect(mockApi.displayTrack).toHaveBeenCalledWith(track, false); + }); + + it('should load map api on api changed in config', (done) => { + // given + spyOn(vm, 'loadMapAPI'); + vm.api = mockApi; + vm.onReady(); + const newApi = 'newapi'; + // when + config.mapApi = newApi; + // then + setTimeout(() => { + expect(vm.loadMapAPI).toHaveBeenCalledTimes(1); + expect(vm.loadMapAPI).toHaveBeenCalledWith(newApi); + done(); + }, 100); + }); + + it('should clear map when state current track is cleared', (done) => { + // given + vm.api = mockApi; + state.currentTrack = null; + vm.onReady(); + uObserve.setSilently(state, 'currentTrack', track); + // when + state.currentTrack = null; + // then + setTimeout(() => { + expect(mockApi.clearMap).toHaveBeenCalledTimes(1); + expect(mockApi.displayTrack).not.toHaveBeenCalled(); + done(); + }, 100); + }); + + it('should display track when state current track is set and update track when new positions are added', (done) => { + // given + vm.api = mockApi; + state.currentTrack = null; + vm.onReady(); + // when + state.currentTrack = track; + // then + setTimeout(() => { + expect(mockApi.clearMap).toHaveBeenCalledTimes(1); + expect(mockApi.displayTrack).toHaveBeenCalledTimes(1); + expect(mockApi.displayTrack).toHaveBeenCalledWith(track, true); + // when + mockApi.displayTrack.calls.reset(); + state.currentTrack.positions.push(TrackFactory.getPosition(100)); + // then + setTimeout(() => { + expect(mockApi.zoomToExtent).toHaveBeenCalledTimes(1); + expect(mockApi.displayTrack).toHaveBeenCalledTimes(1); + expect(mockApi.displayTrack).toHaveBeenCalledWith(track, false); + done(); + }, 100); + }, 100); + }); + + it('should get popup html content', () => { + // given + const id = 0; + spyOn(uUtils, 'sprintf'); + state.currentTrack = TrackFactory.getTrack(2); + // when + const html = vm.getPopupHtml(id); + const element = uUtils.nodeFromHtml(html); + // then + expect(element).toBeInstanceOf(HTMLDivElement); + expect(element.id).toBe('popup'); + expect(uUtils.sprintf.calls.mostRecent().args[1]).toBe(id + 1); + expect(uUtils.sprintf.calls.mostRecent().args[2]).toBe(state.currentTrack.length); + }); + + it('should get popup with stats when track does not contain only latest positions', () => { + // given + const id = 0; + spyOn(uUtils, 'sprintf'); + state.currentTrack = TrackFactory.getTrack(2); + state.showLatest = false; + // when + const html = vm.getPopupHtml(id); + // then + expect(html).toContain('id="pright"'); + }); + + it('should get popup without stats when track contains only latest positions', () => { + // given + const id = 0; + spyOn(uUtils, 'sprintf'); + state.currentTrack = TrackFactory.getTrack(2); + state.showLatest = true; + // when + const html = vm.getPopupHtml(id); + // then + expect(html).not.toContain('id="pright"'); + }); + + it('should get marker svg source with given size and without extra border', () => { + // given + spyOn(MapViewModel, 'getMarkerPath').and.callThrough(); + spyOn(MapViewModel, 'getMarkerExtra').and.callThrough(); + const fill = 'black'; + const isLarge = false; + const isExtra = false; + // when + const dataUri = MapViewModel.getSvgSrc(fill, isLarge, isExtra); + const svgSrc = decodeURIComponent(dataUri.replace(/data:image\/svg\+xml,/, '')); + const element = uUtils.nodeFromHtml(svgSrc); + // then + expect(element).toBeInstanceOf(SVGElement); + expect(svgSrc).toContain(`fill="${fill}"`); + expect(MapViewModel.getMarkerPath).toHaveBeenCalledWith(isLarge); + expect(MapViewModel.getMarkerExtra).not.toHaveBeenCalled(); + }); + + it('should get marker svg source with given size and with extra border', () => { + // given + spyOn(MapViewModel, 'getMarkerPath').and.callThrough(); + spyOn(MapViewModel, 'getMarkerExtra').and.callThrough(); + const fill = 'black'; + const isLarge = true; + const isExtra = true; + // when + const dataUri = MapViewModel.getSvgSrc(fill, isLarge, isExtra); + const svgSrc = decodeURIComponent(dataUri.replace(/data:image\/svg\+xml,/, '')); + const element = uUtils.nodeFromHtml(svgSrc); + // then + expect(element).toBeInstanceOf(SVGElement); + expect(svgSrc).toContain(`fill="${fill}"`); + expect(MapViewModel.getMarkerPath).toHaveBeenCalledWith(isLarge); + expect(MapViewModel.getMarkerExtra).toHaveBeenCalledWith(isLarge); + }); + +});