Add map view model class

This commit is contained in:
Bartek Fabiszewski 2019-12-17 22:19:26 +01:00
parent 28ead5ba86
commit baf39cf818
2 changed files with 508 additions and 0 deletions

210
js/src/mapviewmodel.js Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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}<span class="smaller">${dateTime.zone}</span>`;
}
let provider = '';
if (pos.provider === 'gps') {
provider = ` (<img class="icon" alt="${lang.strings['gps']}" title="${lang.strings['gps']}" src="images/gps_dark.svg">)`;
} else if (pos.provider === 'network') {
provider = ` (<img class="icon" alt="${lang.strings['network']}" title="${lang.strings['network']}" src="images/network_dark.svg">)`;
}
let stats = '';
if (!this.state.showLatest) {
stats =
`<div id="pright">
<img class="icon" alt="${lang.strings['track']}" src="images/stats_blue.svg" style="padding-left: 3em;"><br>
<img class="icon" alt="${lang.strings['ttime']}" title="${lang.strings['ttime']}" src="images/time_blue.svg"> ${lang.getLocaleDuration(pos.totalSeconds)}<br>
<img class="icon" alt="${lang.strings['aspeed']}" title="${lang.strings['aspeed']}" src="images/speed_blue.svg"> ${lang.getLocaleSpeed(pos.totalSpeed, true)}<br>
<img class="icon" alt="${lang.strings['tdistance']}" title="${lang.strings['tdistance']}" src="images/distance_blue.svg"> ${lang.getLocaleDistanceMajor(pos.totalMeters, true)}<br>
</div>`;
}
return `<div id="popup">
<div id="pheader">
<div><img alt="${lang.strings['user']}" title="${lang.strings['user']}" src="images/user_dark.svg"> ${uUtils.htmlEncode(pos.username)}</div>
<div><img alt="${lang.strings['track']}" title="${lang.strings['track']}" src="images/route_dark.svg"> ${uUtils.htmlEncode(pos.trackname)}</div>
</div>
<div id="pbody">
${(pos.hasComment()) ? `<div id="pcomments">${uUtils.htmlEncode(pos.comment)}</div>` : ''}
${(pos.hasImage()) ? `<div id="pimage"><img src="uploads/${pos.image}" alt="image"></div>` : ''}
<div id="pleft">
<img class="icon" alt="${lang.strings['time']}" title="${lang.strings['time']}" src="images/calendar_dark.svg"> ${date}<br>
<img class="icon" alt="${lang.strings['time']}" title="${lang.strings['time']}" src="images/clock_dark.svg"> ${time}<br>
${(pos.speed !== null) ? `<img class="icon" alt="${lang.strings['speed']}" title="${lang.strings['speed']}" src="images/speed_dark.svg">${lang.getLocaleSpeed(pos.speed, true)}<br>` : ''}
${(pos.altitude !== null) ? `<img class="icon" alt="${lang.strings['altitude']}" title="${lang.strings['altitude']}" src="images/altitude_dark.svg">${lang.getLocaleAltitude(pos.altitude, true)}<br>` : ''}
${(pos.accuracy !== null) ? `<img class="icon" alt="${lang.strings['accuracy']}" title="${lang.strings['accuracy']}" src="images/accuracy_dark.svg">${lang.getLocaleAccuracy(pos.accuracy, true)}${provider}<br>` : ''}
</div>${stats}</div>
<div id="pfooter">${uUtils.sprintf(lang.strings['pointof'], id + 1, count)}</div>
</div>`;
}
/**
* 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 `<path fill="none" stroke="red" stroke-width="2" d="${offset1}c0-3.961-3.243-7.167-7.251-7.167"/>
<path fill="none" stroke="red" stroke-width="2" d="${offset2}c-0.5-4.028-3.642-7.083-7.724-7.542"/>`;
}
/**
* Get inline SVG source
* @param {string} fill
* @param {boolean=} isLarge
* @param {boolean=} isExtra
* @return {string}
*/
static getSvgSrc(fill, isLarge, isExtra) {
const svg = `<svg viewBox="0 0 30 35" width="30px" height="35px" xmlns="http://www.w3.org/2000/svg">
<g><path stroke="black" fill="${fill}" d="${MapViewModel.getMarkerPath(isLarge)}"/>${isExtra ? MapViewModel.getMarkerExtra(isLarge) : ''}</g></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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 = `<div id="fixture">
<div id="map-canvas"></div>
</div>`;
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);
});
});