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 */ }
});