/* * μ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 { lang as $, auth, config } from './initializer.js'; import GoogleMapsApi from './mapapi/api_gmaps.js'; import OpenLayersApi from './mapapi/api_openlayers.js'; import PositionDialogModel from './positiondialogmodel.js'; import ViewModel from './viewmodel.js'; import uAlert from './alert.js'; import uDialog from './dialog.js'; import uObserve from './observe.js'; import uUtils from './utils.js'; /** * @typedef {Object} MapViewModel.api * @interface * @memberOf MapViewModel * @type {Object} * @property {function(number)} animateMarker * @property {function(uTrack, boolean)} displayTrack * @property {function} cleanup * @property {function} clearMap * @property {function} getBounds * @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 * @property {number} zoom * @property {number} rotation */ /** * @class MapViewModel */ export default class MapViewModel extends ViewModel { /** * @param {uState} state */ constructor(state) { super({ /** @type {?number} */ markerOver: null, /** @type {?number} */ markerSelect: null, // click handler onMenuToggle: null, speedVisible: false, altitudeVisible: false }); 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; } /** * @return {MapViewModel} */ init() { this.bindAll(); this.setObservers(); return this; } /** * Dynamic change of map api * @param {string} apiName API name */ loadMapAPI(apiName) { let mapApi = this.api; this.api = null; if (mapApi) { try { this.savedBounds = mapApi.getBounds(); } catch (e) { this.savedBounds = null; } mapApi.cleanup(); } mapApi = this.getApi(apiName); mapApi.init() .then(() => { this.api = mapApi; this.onReady(); }) .catch((e) => { let txt = $._('apifailure', apiName); if (e && e.message) { txt += ` (${e.message})`; } uAlert.error(txt, e); 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.state.currentTrack) { let update = true; if (this.savedBounds) { this.api.zoomToBounds(this.savedBounds); update = false; } this.displayTrack(this.state.currentTrack, update); } } setObservers() { config.onChanged('mapApi', (mapApi) => { this.loadMapAPI(mapApi); this.toggleStyleOptions(); this.toggleStyleMenu(); }); this.state.onChanged('currentTrack', (track) => { if (!this.api) { return; } this.api.clearMap(); if (track) { uObserve.observe(track, 'positions', () => { this.displayTrack(track, false); this.api.zoomToExtent(); this.toggleStyleOptions(); }); this.displayTrack(track, true); } this.toggleStyleOptions(); }); this.state.onChanged('history', () => { const history = this.state.history; if (this.api && history && !history.trackId) { if (history.mapApi) { config.mapApi = history.mapApi; } else { if (history.mapParams) { this.api.updateState(history.mapParams); } else { this.api.zoomToExtent(); } this.state.history = null; } } }); 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(); }); } /** * @param {uTrack} track Track to display * @param {boolean} update Should update map view */ displayTrack(track, update) { this.state.jobStart(); if (update && this.state.history && this.state.history.mapParams) { this.api.updateState(this.state.history.mapParams); 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 * @returns {HTMLDivElement} */ getPopupElement(id) { const pos = this.state.currentTrack.positions[id]; const count = this.state.currentTrack.length; const user = this.state.currentTrack.user; const isEditable = auth.user && (auth.isAdmin || auth.user.id === user.id); 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 = ` ${$._('gps')}`; } else if (pos.provider === 'network') { provider = ` ${$._('network')}`; } let editLink = ''; if (isEditable) { editLink = `${$._('editposition')}`; } let stats = ''; if (!this.state.showLatest) { stats = `
${$._('track')}
${$._('ttime')} ${$.getLocaleDuration(pos.totalSeconds)}
${$._('aspeed')} ${$.getLocaleSpeed(pos.totalSpeed, true)}
${$._('tdistance')} ${$.getLocaleDistanceMajor(pos.totalMeters, true)}
`; } const html = `
${$._('user')} ${uUtils.htmlEncode(pos.username)}
${$._('track')} ${uUtils.htmlEncode(pos.trackname)}
${(pos.hasComment()) ? `
${uUtils.htmlEncode(pos.comment).replace(/\n/, '
')}
` : ''} ${(pos.hasImage()) ? `
image
` : ''}
${$._('time')} ${date}
${$._('time')} ${time}
${(pos.speed !== null) ? `${$._('speed')}${$.getLocaleSpeed(pos.speed, true)}
` : ''} ${(pos.altitude !== null) ? `${$._('altitude')}${$.getLocaleAltitude(pos.altitude, true)}
` : ''} ${(pos.accuracy !== null) ? `${$._('accuracy')}${$.getLocaleAccuracy(pos.accuracy, true)}${provider}
` : ''} ${(pos.bearing !== null) ? `${$._('bearing')}${pos.bearing}°
` : ''} ${$._('position')}${$.getLocaleCoordinates(pos)}
${stats}
${$._('pointof', id + 1, count)}
${editLink}
`; const node = document.createElement('div'); node.setAttribute('id', 'popup'); node.innerHTML = html; if (pos.hasImage()) { const image = node.querySelector('#pimage img'); image.onclick = () => { const modal = new uDialog(`image`); const closeEl = modal.element.querySelector('#modal-close'); closeEl.onclick = () => modal.destroy(); modal.element.classList.add('image'); modal.show(); } } if (isEditable) { const edit = node.querySelector('#editposition'); edit.onclick = () => { const vm = new PositionDialogModel(this.state, id); vm.init(); } } return node; } /** * 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)}`; } }