diff --git a/css/src/main.css b/css/src/main.css index 6c394f1..142e28c 100644 --- a/css/src/main.css +++ b/css/src/main.css @@ -472,16 +472,6 @@ button > * { text-decoration: underline; } -.loader { - animation: blink 1s linear infinite; -} - -@keyframes blink { - 50% { - opacity: 0; - } -} - #configForm label { display: block; } @@ -502,8 +492,6 @@ button > * { width: 150px; margin: 3px 0; padding: 2px 4px; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; box-sizing: border-box; } @@ -526,6 +514,8 @@ button > * { margin: 0 5px; } +/* alert */ + .alert { position: fixed; top: 0; @@ -545,6 +535,8 @@ button > * { border-top: 1px solid #555; box-shadow: 10px 10px 10px -8px rgba(0, 0, 0, 0.3); z-index: 100000; + opacity: 0; + transition: all 1s; } .alert.error { @@ -552,6 +544,14 @@ button > * { border-top: 1px solid #d05858; } +.alert.in { + opacity: 1; +} + +.alert.out { + opacity: 0; +} + .alert button { position: absolute; top: -1px; @@ -564,6 +564,67 @@ button > * { font-size: 15px; } +.alert.spinner { + background-color: transparent; + border: none; + box-shadow: none; +} + +.alert.spinner > span { + position: relative; + display: block; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #9880ff; + color: #9880ff; + animation: spinner-dot 1s infinite linear alternate; + animation-delay: 0.5s; + transform: translateZ(0); + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + will-change: transform, opacity; +} + +.alert.spinner > span::after { + left: 15px; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #9880ff; + color: #9880ff; + animation: spinner-dot 1s infinite alternate; + animation-delay: 1s; +} + +.alert.spinner > span::before, .alert.spinner > span::after { + content: ''; + display: inline-block; + position: absolute; + top: 0; +} + +.alert.spinner > span::before { + left: -15px; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #9880ff; + color: #9880ff; + animation: spinner-dot 1s infinite alternate; + animation-delay: 0s; +} + +@keyframes spinner-dot { + 0% { + background-color: #9880ff; + } + + 50%, 100% { + background-color: #ebe6ff; + } +} + /* chart */ .ct-point { transition: 0.3s; @@ -592,7 +653,7 @@ button > * { font-size: 0.8em; } -/* openlayers 3 popup */ +/* openlayers popup */ .ol-popup { position: absolute; bottom: 12px; diff --git a/js/src/alert.js b/js/src/alert.js index 4b661b9..69e2e36 100644 --- a/js/src/alert.js +++ b/js/src/alert.js @@ -24,8 +24,10 @@ export default class uAlert { /** * @typedef {Object} AlertOptions * @property {number} [autoClose=0] Optional autoclose delay time in ms, default 0 – no autoclose + * @property {boolean} [hasButton] Optional can be closed by button click, default true when autoClose not set * @property {string} [id] Optional box id * @property {string} [class] Optional box class + * @property {boolean} [fixed=false] Optional set fixed position, default false */ /** @@ -35,6 +37,8 @@ export default class uAlert { */ constructor(message, options = {}) { this.autoClose = options.autoClose || 0; + this.hasButton = typeof options.hasButton !== 'undefined' ? options.hasButton : this.autoClose === 0 + this.fixedPosition = options.fixed || false; const html = `
${message}
`; this.box = uUtils.nodeFromHtml(html); if (options.id) { @@ -43,7 +47,7 @@ export default class uAlert { if (options.class) { this.box.classList.add(options.class); } - if (this.autoClose === 0) { + if (this.hasButton) { const button = document.createElement('button'); button.setAttribute('type', 'button'); button.textContent = '×'; @@ -72,11 +76,18 @@ export default class uAlert { } render() { - const top = uAlert.getPosition(); - if (top) { - this.box.style.top = `${top}px`; + if (!this.fixedPosition) { + const top = uAlert.getPosition(); + if (top) { + this.box.style.top = `${top}px`; + } } document.body.appendChild(this.box); + setTimeout(() => { + if (this.box) { + this.box.classList.add('in'); + } + }, 50); } destroy() { @@ -84,10 +95,14 @@ export default class uAlert { clearTimeout(this.closeHandle); this.closeHandle = null; } - if (this.box) { - if (document.body.contains(this.box)) { - document.body.removeChild(this.box); - } + if (this.box && document.body.contains(this.box)) { + const element = this.box; + requestAnimationFrame(() => { + element.classList.add('out'); + setTimeout(() => { + element.remove(); + }, 1000); + }); this.box = null; } } @@ -129,4 +144,8 @@ export default class uAlert { return this.show(message, { class: 'toast', autoClose: 10000 }); } + static spinner() { + return this.show('', { class: 'spinner', hasButton: false, fixed: true }); + } + } diff --git a/js/src/mapapi/api_gmaps.js b/js/src/mapapi/api_gmaps.js index d630131..100a1c5 100644 --- a/js/src/mapapi/api_gmaps.js +++ b/js/src/mapapi/api_gmaps.js @@ -129,11 +129,18 @@ export default class GoogleMapsApi { * Display track * @param {uPositionSet} track * @param {boolean} update Should fit bounds if true + * @return {Promise.} */ displayTrack(track, update) { if (!track || !track.hasPositions) { - return; + return Promise.resolve(); } + const promise = new Promise((resolve) => { + google.maps.event.addListenerOnce(this.map, 'tilesloaded', () => { + console.log('tilesloaded'); + resolve(); + }) + }); // init polyline const polyOptions = { strokeColor: config.strokeColor, @@ -184,6 +191,7 @@ export default class GoogleMapsApi { }, 2000); } } + return promise; } /** diff --git a/js/src/mapapi/api_openlayers.js b/js/src/mapapi/api_openlayers.js index 79efcc6..10eb31a 100644 --- a/js/src/mapapi/api_openlayers.js +++ b/js/src/mapapi/api_openlayers.js @@ -424,11 +424,18 @@ export default class OpenLayersApi { * Display track * @param {uPositionSet} track Track * @param {boolean} update Should fit bounds if true + * @return {Promise.} */ displayTrack(track, update) { if (!track || !track.hasPositions) { - return; + return Promise.resolve(); } + const promise = new Promise((resolve) => { + this.map.once('rendercomplete', () => { + console.log('rendercomplete'); + resolve(); + }); + }); let start = this.layerMarkers ? this.layerMarkers.getSource().getFeatures().length : 0; if (start > 0) { this.removePoint(--start); @@ -457,6 +464,7 @@ export default class OpenLayersApi { extent = this.fitToExtent(extent); } this.setZoomToExtent(extent); + return promise; } /** diff --git a/js/src/mapviewmodel.js b/js/src/mapviewmodel.js index 36a5757..0d7ddbd 100644 --- a/js/src/mapviewmodel.js +++ b/js/src/mapviewmodel.js @@ -120,7 +120,7 @@ export default class MapViewModel extends ViewModel { this.api.zoomToBounds(this.savedBounds); } if (this.state.currentTrack) { - this.api.displayTrack(this.state.currentTrack, this.savedBounds === null); + this.displayTrack(this.state.currentTrack, this.savedBounds === null); } } @@ -135,14 +135,20 @@ export default class MapViewModel extends ViewModel { this.api.clearMap(); if (track) { uObserve.observe(track, 'positions', () => { - this.api.displayTrack(track, false); + this.displayTrack(track, false); this.api.zoomToExtent(); }); - this.api.displayTrack(track, true); + this.displayTrack(track, true); } }); } + displayTrack(track, update) { + this.state.jobStart(); + this.api.displayTrack(track, update) + .finally(() => this.state.jobStop()); + } + /** * Get popup html * @param {number} id Position index diff --git a/js/src/spinner.js b/js/src/spinner.js new file mode 100644 index 0000000..74176a3 --- /dev/null +++ b/js/src/spinner.js @@ -0,0 +1,42 @@ +/* + * μlogger + * + * Copyright(C) 2020 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 uAlert from './alert.js'; + +export default class uSpinner { + + constructor(state) { + this.spinner = null; + this.state = state; + } + + init() { + this.state.onChanged('activeJobs', (jobs) => { + if (jobs > 0) { + if (!this.spinner) { + this.spinner = uAlert.spinner(); + } + } else if (this.spinner) { + this.spinner.destroy(); + this.spinner = null; + } + }); + } +} diff --git a/js/src/state.js b/js/src/state.js index 7a27050..ca7fe79 100644 --- a/js/src/state.js +++ b/js/src/state.js @@ -25,6 +25,7 @@ import uObserve from './observe.js'; * @property {?uUser} currentUser * @property {boolean} showLatest * @property {boolean} showAllUsers + * @property {number} activeJobs */ export default class uState { @@ -33,6 +34,15 @@ export default class uState { this.currentUser = null; this.showLatest = false; this.showAllUsers = false; + this.activeJobs = 0; + } + + jobStart() { + this.activeJobs++; + } + + jobStop() { + this.activeJobs--; } /** diff --git a/js/src/trackviewmodel.js b/js/src/trackviewmodel.js index 0f1bcc5..e05d310 100644 --- a/js/src/trackviewmodel.js +++ b/js/src/trackviewmodel.js @@ -173,6 +173,7 @@ export default class TrackViewModel extends ViewModel { uAlert.error($._('notauthorized')); return; } + this.state.jobStart(); uTrack.import(form, auth.user) .then((trackList) => { if (trackList.length) { @@ -186,6 +187,7 @@ export default class TrackViewModel extends ViewModel { .catch((e) => uAlert.error(`${$._('actionfailure')}\n${e.message}`, e)) .finally(() => { this.model.inputFile = ''; + this.state.jobStop(); }); } @@ -199,6 +201,7 @@ export default class TrackViewModel extends ViewModel { if (!track) { this.state.currentTrack = null; } else if (!track.isEqualTo(this.state.currentTrack)) { + this.state.jobStart(); track.fetchPositions().then(() => { console.log(`currentTrack id: ${track.id}, loaded ${track.length} positions`); this.state.currentTrack = track; @@ -206,7 +209,8 @@ export default class TrackViewModel extends ViewModel { this.model.showLatest = false; } }) - .catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); }); + .catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); }) + .finally(() => this.state.jobStop()); } } @@ -243,6 +247,7 @@ export default class TrackViewModel extends ViewModel { * Handle last position of all users request */ loadAllUsersPosition() { + this.state.jobStart(); uPositionSet.fetchLatest() .then((_track) => { if (_track) { @@ -251,10 +256,12 @@ export default class TrackViewModel extends ViewModel { this.state.currentTrack = _track; } }) - .catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); }); + .catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); }) + .finally(() => this.state.jobStop()); } loadTrackList() { + this.state.jobStart(); uTrack.fetchList(this.state.currentUser) .then((_tracks) => { this.model.trackList = _tracks; @@ -269,7 +276,8 @@ export default class TrackViewModel extends ViewModel { this.model.currentTrackId = ''; } }) - .catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); }); + .catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); }) + .finally(() => this.state.jobStop()); } showDialog() { diff --git a/js/src/ulogger.js b/js/src/ulogger.js index 80139cb..d7cb797 100644 --- a/js/src/ulogger.js +++ b/js/src/ulogger.js @@ -25,6 +25,7 @@ import MapViewModel from './mapviewmodel.js'; import TrackViewModel from './trackviewmodel.js'; import UserViewModel from './userviewmodel.js'; import uAlert from './alert.js'; +import uSpinner from './spinner.js'; import uState from './state.js'; const domReady = uInitializer.waitForDom(); @@ -39,13 +40,14 @@ Promise.all([ domReady, initReady ]) function start() { const state = new uState(); - + const spinner = new uSpinner(state); const mainVM = new MainViewModel(state); const userVM = new UserViewModel(state); const trackVM = new TrackViewModel(state); const mapVM = new MapViewModel(state); const chartVM = new ChartViewModel(state); const configVM = new ConfigViewModel(state); + spinner.init(); mainVM.init(); userVM.init(); trackVM.init(); diff --git a/js/test/alert.test.js b/js/test/alert.test.js index 6c3ff4c..435efc8 100644 --- a/js/test/alert.test.js +++ b/js/test/alert.test.js @@ -24,6 +24,10 @@ describe('Alert tests', () => { const message = 'test message'; let alert; + beforeEach(() => { + spyOn(window, 'requestAnimationFrame').and.callFake((callback) => callback()); + }) + afterEach(() => { if (alert) { alert.destroy(); @@ -79,6 +83,7 @@ describe('Alert tests', () => { it('should render and destroy alert box', () => { // given + spyOn(window, 'setTimeout').and.callFake((callback) => callback()); const id = 'testId'; const options = { id } alert = new uAlert(message, options); @@ -96,6 +101,7 @@ describe('Alert tests', () => { it('should show and autoclose alert box', (done) => { // given + jasmine.clock().install(); const id = 'testId'; const options = { id: id, autoClose: 50 } @@ -103,7 +109,8 @@ describe('Alert tests', () => { alert = uAlert.show(message, options); // then expect(document.querySelector(`#${id}`)).not.toBeNull(); - + jasmine.clock().tick(5000); + jasmine.clock().uninstall(); setTimeout(() => { expect(document.querySelector(`#${id}`)).toBeNull(); done(); @@ -112,12 +119,15 @@ describe('Alert tests', () => { it('should close alert box on close button click', (done) => { // given + jasmine.clock().install(); const id = 'testId'; const options = { id } alert = uAlert.show(message, options); const closeButton = alert.box.querySelector('button'); // when closeButton.click(); + jasmine.clock().tick(5000); + jasmine.clock().uninstall(); // then setTimeout(() => { expect(document.querySelector(`#${id}`)).toBeNull(); diff --git a/js/test/api_gmaps.test.js b/js/test/api_gmaps.test.js index 5e071f6..77cbd9c 100644 --- a/js/test/api_gmaps.test.js +++ b/js/test/api_gmaps.test.js @@ -281,7 +281,7 @@ describe('Google Maps map API tests', () => { expect(api.polies[0].path.length).toBe(track.length); expect(api.setMarker).toHaveBeenCalledTimes(track.length); expect(google.maps.Map.prototype.fitBounds).toHaveBeenCalledTimes(1); - expect(google.maps.event.addListenerOnce).not.toHaveBeenCalled(); + expect(google.maps.event.addListenerOnce).toHaveBeenCalledTimes(1); expect(setTimeout).not.toHaveBeenCalled(); }); diff --git a/js/test/mapviewmodel.test.js b/js/test/mapviewmodel.test.js index 601f048..108778e 100644 --- a/js/test/mapviewmodel.test.js +++ b/js/test/mapviewmodel.test.js @@ -56,7 +56,7 @@ describe('MapViewModel tests', () => { 'cleanup': { /* ignored */ }, 'zoomToBounds': { /* ignored */ }, 'zoomToExtent': { /* ignored */ }, - 'displayTrack': { /* ignored */ }, + 'displayTrack': Promise.resolve(), 'clearMap': { /* ignored */ }, 'updateSize': { /* ignored */ } });