improve styling of drawer changes
[lantea.git] / js / ui.js
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5 // Get the best-available objects for indexedDB and requestAnimationFrame.
6 window.indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB;
7 window.requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
8
9 var mainDB;
10 var gAppInitDone = false;
11 var gUIHideCountdown = 0;
12 var gWaitCounter = 0;
13 var gTrackUpdateInterval;
14 var gAction, gActionLabel;
15 var gBackendURL = "https://backend.lantea.kairo.at";
16 var gAuthClientID = "lantea";
17
18 window.onload = function() {
19   if (/\/login\.html/.test(window.location)) {
20     // If we are in the login window, call a function to complete the process and don't do anything else here.
21     completeLoginWindow();
22     return;
23   }
24   gAction = document.getElementById("action");
25   gActionLabel = document.getElementById("actionlabel");
26
27   var mSel = document.getElementById("mapSelector");
28   for (var mapStyle in gMapStyles) {
29     var opt = document.createElement("option");
30     opt.value = mapStyle;
31     opt.text = gMapStyles[mapStyle].name;
32     mSel.add(opt, null);
33   }
34
35   var areas = document.getElementsByClassName("overlayArea");
36   for (var i = 0; i <= areas.length - 1; i++) {
37     areas[i].addEventListener("mouseup", uiEvHandler, false);
38     areas[i].addEventListener("mousemove", uiEvHandler, false);
39     areas[i].addEventListener("mousedown", uiEvHandler, false);
40     areas[i].addEventListener("mouseout", uiEvHandler, false);
41
42     areas[i].addEventListener("touchstart", uiEvHandler, false);
43     areas[i].addEventListener("touchmove", uiEvHandler, false);
44     areas[i].addEventListener("touchend", uiEvHandler, false);
45     areas[i].addEventListener("touchcancel", uiEvHandler, false);
46     areas[i].addEventListener("touchleave", uiEvHandler, false);
47   }
48
49   document.getElementById("body").addEventListener("keydown", uiEvHandler, false);
50
51   if (navigator.platform.length == "") {
52     // For Firefox OS, don't display the "save" button.
53     // Do this by setting the debugHide class for testing in debug mode.
54     document.getElementById("saveTrackButton").classList.add("debugHide");
55   }
56
57   // Set backend URL in a way that it works for testing on localhost as well as
58   // both the lantea.kairo.at and lantea-dev.kairo.at deployments.
59   if (window.location.host == "localhost") {
60     gBackendURL = window.location.protocol + '//' + window.location.host + "/lantea-backend/";
61   }
62   else {
63     gBackendURL = window.location.protocol + '//' + "backend." + window.location.host + "/";
64   }
65   // Make sure to use a different login client ID for the -dev setup.
66   if (/\-dev\./.test(window.location.host)) {
67     gAuthClientID += "-dev";
68   }
69
70   // Set up the login area.
71   document.getElementById("loginbtn").onclick = startLogin;
72   document.getElementById("logoutbtn").onclick = doLogout;
73   prepareLoginButton(function() {
74     // Anything that needs the backend should only be triggered from in here.
75     // That makes sure that the first call the the backend is oauth_state and no other is running in parallel.
76     // If we call multiple backend methods at once and no session is open, we create multiple sessions, which calls for confusion later on.
77
78     // Call any UI preparation that needs the backend.
79   });
80
81   gAction.addEventListener("dbinit-done", initMap, false);
82   gAction.addEventListener("mapinit-done", postInit, false);
83   console.log("starting DB init...");
84   initDB();
85 }
86
87 function postInit(aEvent) {
88   gAction.removeEventListener(aEvent.type, postInit, false);
89   console.log("init done, draw map.");
90   gMapPrefsLoaded = true;
91   gAppInitDone = true;
92   //gMap.resizeAndDraw();  <-- HACK: This triggers bug 1001853, work around with a delay.
93   window.setTimeout(gMap.resizeAndDraw, 100);
94   gActionLabel.textContent = "";
95   gAction.style.display = "none";
96   setTracking(document.getElementById("trackCheckbox"));
97   gPrefs.get("devicename", function(aValue) {
98     if (aValue) {
99       document.getElementById("uploadDevName").value = aValue;
100     }
101   });
102 }
103
104 window.onresize = function() {
105   gMap.resizeAndDraw();
106 }
107
108 function startLogin() {
109   var authURL = authData["url"] + "authorize?response_type=code&client_id=" + gAuthClientID + "&scope=email" +
110                 "&state=" + authData["state"] + "&redirect_uri=" + encodeURIComponent(getRedirectURI());
111   if (window.open(authURL, "KaiRoAuth", 'height=450,width=600')) {
112     console.log("Sign In window open.");
113   }
114   else {
115     console.log("Opening Sign In window failed.");
116   }
117 }
118
119 function getRedirectURI() {
120   return window.location.protocol + '//' + window.location.host + window.location.pathname.replace("index.html", "") + "login.html";
121 }
122
123 function doLogout() {
124   fetchBackend("logout", "GET", null,
125      function(aResult, aStatus) {
126         if (aStatus < 400) {
127           prepareLoginButton();
128         }
129         else {
130           console.log("Backend issue trying to log out.");
131         }
132       },
133       {}
134   );
135 }
136
137 function prepareLoginButton(aCallback) {
138   fetchBackend("oauth_state", "GET", null,
139       function(aResult, aStatus) {
140         if (aStatus == 200) {
141           if (aResult["logged_in"]) {
142             userData = {
143               "email": aResult["email"],
144               "permissions": aResult["permissions"],
145             };
146             authData = null;
147             displayLogin();
148           }
149           else {
150             authData = {"state": aResult["state"], "url": aResult["url"]};
151             userData = null;
152             displayLogout();
153           }
154         }
155         else {
156           console.log("Backend error " + aStatus + " fetching OAuth state: " + aResult["message"]);
157         }
158         if (aCallback) { aCallback(); }
159       },
160       {}
161   );
162 }
163
164 function completeLoginWindow() {
165   if (window.opener) {
166     window.opener.finishLogin(getParameterByName("code"), getParameterByName("state"));
167     window.close();
168   }
169   else {
170     document.getElementById("logininfo").textContent = "You have called this document outside of the login flow, which is not supported.";
171   }
172 }
173
174 function finishLogin(aCode, aState) {
175   if (aState == authData["state"]) {
176     fetchBackend("login?code=" + aCode + "&state=" + aState + "&redirect_uri=" + encodeURIComponent(getRedirectURI()), "GET", null,
177         function(aResult, aStatus) {
178           if (aStatus == 200) {
179             userData = {
180               "email": aResult["email"],
181               "permissions": aResult["permissions"],
182             };
183             displayLogin();
184           }
185           else {
186             console.log("Login error " + aStatus + ": " + aResult["message"]);
187             prepareLoginButton();
188           }
189         },
190         {}
191     );
192   }
193   else {
194     console.log("Login state did not match, not continuing with login.");
195   }
196 }
197
198 function displayLogin() {
199   document.getElementById("loginbtn").classList.add("hidden");
200   document.getElementById("logindesc").classList.add("hidden");
201   document.getElementById("username").classList.remove("hidden");
202   document.getElementById("username").textContent = userData.email;
203   document.getElementById("uploadTrackButton").disabled = false;
204   document.getElementById("logoutbtn").classList.remove("hidden");
205 }
206
207 function displayLogout() {
208   document.getElementById("logoutbtn").classList.add("hidden");
209   document.getElementById("username").classList.add("hidden");
210   document.getElementById("username").textContent = "";
211   document.getElementById("uploadTrackButton").disabled = true;
212   document.getElementById("loginbtn").classList.remove("hidden");
213   document.getElementById("logindesc").classList.remove("hidden");
214 }
215
216 function initDB(aEvent) {
217   // Open DB.
218   if (aEvent)
219     gAction.removeEventListener(aEvent.type, initDB, false);
220   var request = window.indexedDB.open("MainDB-lantea", 2);
221   request.onerror = function(event) {
222     // Errors can be handled here. Error codes explain in:
223     // https://developer.mozilla.org/en/IndexedDB/IDBDatabaseException#Constants
224     if (gDebug)
225       console.log("error opening mainDB: " + event.target.errorCode);
226   };
227   request.onsuccess = function(event) {
228     mainDB = request.result;
229     var throwEv = new CustomEvent("dbinit-done");
230     gAction.dispatchEvent(throwEv);
231   };
232   request.onupgradeneeded = function(event) {
233     mainDB = request.result;
234     var ver = mainDB.version || 0; // version is empty string for a new DB
235     if (gDebug)
236       console.log("mainDB has version " + ver + ", upgrade needed.");
237     if (!mainDB.objectStoreNames.contains("prefs")) {
238       // Create a "prefs" objectStore.
239       var prefsStore = mainDB.createObjectStore("prefs");
240     }
241     if (!mainDB.objectStoreNames.contains("track")) {
242       // Create a "track" objectStore.
243       var trackStore = mainDB.createObjectStore("track", {autoIncrement: true});
244     }
245     if (!mainDB.objectStoreNames.contains("tilecache")) {
246       // Create a "tilecache" objectStore.
247       var tilecacheStore = mainDB.createObjectStore("tilecache");
248     }
249     mainDB.onversionchange = function(event) {
250       mainDB.close();
251       mainDB = undefined;
252       initDB();
253     };
254   };
255 }
256
257 function showUI() {
258   if (gUIHideCountdown <= 0) {
259     var areas = document.getElementsByClassName('overlayArea');
260     for (var i = 0; i <= areas.length - 1; i++) {
261       areas[i].classList.remove("hidden");
262     }
263     setTimeout(maybeHideUI, 1000);
264   }
265   gUIHideCountdown = 5;
266 }
267
268 function maybeHideUI() {
269   gUIHideCountdown--;
270   if (gUIHideCountdown <= 0) {
271     var areas = document.getElementsByClassName('overlayArea');
272     for (var i = 0; i <= areas.length - 1; i++) {
273       areas[i].classList.add("hidden");
274     }
275   }
276   else {
277     setTimeout(maybeHideUI, 1000);
278   }
279 }
280
281 function updateTrackInfo() {
282   document.getElementById("trackLengthNum").textContent = calcTrackLength().toFixed(1);
283   var duration = calcTrackDuration();
284   var durationM = Math.round(duration/60);
285   var durationH = Math.floor(durationM/60); durationM = durationM - durationH * 60;
286   document.getElementById("trackDurationH").style.display = durationH ? "inline" : "none";
287   document.getElementById("trackDurationHNum").textContent = durationH;
288   document.getElementById("trackDurationMNum").textContent = durationM;
289 }
290
291 function toggleTrackArea() {
292   var fs = document.getElementById("trackArea");
293   if (fs.classList.contains("hidden")) {
294     fs.classList.remove("hidden");
295     showUI();
296     gTrackUpdateInterval = setInterval(updateTrackInfo, 1000);
297   }
298   else {
299     clearInterval(gTrackUpdateInterval);
300     fs.classList.add("hidden");
301   }
302 }
303
304 function toggleSettings() {
305   var fs = document.getElementById("settingsArea");
306   if (fs.classList.contains("hidden")) {
307     fs.classList.remove("hidden");
308     showUI();
309   }
310   else {
311     fs.classList.add("hidden");
312   }
313 }
314
315 function toggleFullscreen() {
316   if ((document.fullScreenElement && document.fullScreenElement !== null) ||
317       (document.mozFullScreenElement && document.mozFullScreenElement !== null) ||
318       (document.webkitFullScreenElement && document.webkitFullScreenElement !== null)) {
319     if (document.cancelFullScreen) {
320       document.cancelFullScreen();
321     } else if (document.mozCancelFullScreen) {
322       document.mozCancelFullScreen();
323     } else if (document.webkitCancelFullScreen) {
324       document.webkitCancelFullScreen();
325     }
326   }
327   else {
328     var elem = document.getElementById("body");
329     if (elem.requestFullScreen) {
330       elem.requestFullScreen();
331     } else if (elem.mozRequestFullScreen) {
332       elem.mozRequestFullScreen();
333     } else if (elem.webkitRequestFullScreen) {
334       elem.webkitRequestFullScreen();
335     }
336   }
337 }
338
339 function showUploadDialog() {
340   var dia = document.getElementById("trackDialogArea");
341   var areas = dia.children;
342   for (var i = 0; i <= areas.length - 1; i++) {
343     areas[i].style.display = "none";
344   }
345   document.getElementById("uploadDialog").style.display = "block";
346   document.getElementById("uploadTrackButton").disabled = true;
347   dia.classList.remove("hidden");
348 }
349
350 function showGLWarningDialog() {
351   var dia = document.getElementById("dialogArea");
352   var areas = dia.children;
353   for (var i = 0; i <= areas.length - 1; i++) {
354     areas[i].style.display = "none";
355   }
356   document.getElementById("noGLwarning").style.display = "block";
357   dia.classList.remove("hidden");
358 }
359
360 function cancelTrackDialog() {
361   document.getElementById("trackDialogArea").classList.add("hidden");
362   document.getElementById("uploadTrackButton").disabled = false;
363 }
364
365 var uiEvHandler = {
366   handleEvent: function(aEvent) {
367     var touchEvent = aEvent.type.indexOf('touch') != -1;
368
369     switch (aEvent.type) {
370       case "mousedown":
371       case "touchstart":
372       case "mousemove":
373       case "touchmove":
374       case "mouseup":
375       case "touchend":
376       case "keydown":
377         showUI();
378         break;
379     }
380   }
381 };
382
383 function setUploadField(aField) {
384   switch (aField.id) {
385     case "uploadDevName":
386       gPrefs.set("devicename", aField.value);
387       break;
388   }
389 }
390
391 function makeISOString(aTimestamp) {
392   // ISO time format is YYYY-MM-DDTHH:mm:ssZ
393   var tsDate = new Date(aTimestamp);
394   // Note that .getUTCMonth() returns a number between 0 and 11 (0 for January)!
395   return tsDate.getUTCFullYear() + "-" +
396          (tsDate.getUTCMonth() < 9 ? "0" : "") + (tsDate.getUTCMonth() + 1 ) + "-" +
397          (tsDate.getUTCDate() < 10 ? "0" : "") + tsDate.getUTCDate() + "T" +
398          (tsDate.getUTCHours() < 10 ? "0" : "") + tsDate.getUTCHours() + ":" +
399          (tsDate.getUTCMinutes() < 10 ? "0" : "") + tsDate.getUTCMinutes() + ":" +
400          (tsDate.getUTCSeconds() < 10 ? "0" : "") + tsDate.getUTCSeconds() + "Z";
401 }
402
403 function convertTrack(aTargetFormat) {
404   var out = "";
405   switch (aTargetFormat) {
406     case "gpx":
407       out += '<?xml version="1.0" encoding="UTF-8" ?>' + "\n\n";
408       out += '<gpx version="1.0" creator="Lantea" xmlns="http://www.topografix.com/GPX/1/0">' + "\n";
409       if (gTrack.length) {
410         out += '  <trk>' + "\n";
411         out += '    <trkseg>' + "\n";
412         for (var i = 0; i < gTrack.length; i++) {
413           if (gTrack[i].beginSegment && i > 0) {
414             out += '    </trkseg>' + "\n";
415             out += '    <trkseg>' + "\n";
416           }
417           out += '      <trkpt lat="' + gTrack[i].coords.latitude + '" lon="' +
418                                         gTrack[i].coords.longitude + '">' + "\n";
419           if (gTrack[i].coords.altitude) {
420             out += '        <ele>' + gTrack[i].coords.altitude + '</ele>' + "\n";
421           }
422           out += '        <time>' + makeISOString(gTrack[i].time) + '</time>' + "\n";
423           out += '      </trkpt>' + "\n";
424         }
425         out += '    </trkseg>' + "\n";
426         out += '  </trk>' + "\n";
427       }
428       out += '</gpx>' + "\n";
429       break;
430     case "json":
431       out = JSON.stringify(gTrack);
432       break;
433     default:
434       break;
435   }
436   return out;
437 }
438
439 function saveTrack() {
440   if (gTrack.length) {
441     var outDataURI = "data:application/gpx+xml," +
442                      encodeURIComponent(convertTrack("gpx"));
443     window.open(outDataURI, 'GPX Track');
444   }
445 }
446
447 function saveTrackDump() {
448   if (gTrack.length) {
449     var outDataURI = "data:application/json," +
450                      encodeURIComponent(convertTrack("json"));
451     window.open(outDataURI, 'JSON dump');
452   }
453 }
454
455 function uploadTrack() {
456   // Hide all areas in the dialog.
457   var dia = document.getElementById("trackDialogArea");
458   var areas = dia.children;
459   for (var i = 0; i <= areas.length - 1; i++) {
460     areas[i].style.display = "none";
461   }
462   // Reset all the fields in the status area.
463   document.getElementById("uploadStatusCloseButton").disabled = true;
464   document.getElementById("uploadInProgress").style.display = "block";
465   document.getElementById("uploadSuccess").style.display = "none";
466   document.getElementById("uploadFailed").style.display = "none";
467   document.getElementById("uploadError").style.display = "none";
468   document.getElementById("uploadErrorMsg").textContent = "";
469   // Now show the status area.
470   document.getElementById("uploadStatus").style.display = "block";
471
472   // Assemble field to post to the backend.
473   var formData = new FormData();
474   formData.append("jsondata", convertTrack("json"));
475   var desc = document.getElementById("uploadDesc").value;
476   formData.append("comment",
477                   desc.length ? desc : "Track recorded via Lantea Maps");
478   formData.append("devicename",
479                   document.getElementById("uploadDevName").value);
480   formData.append("public",
481                   document.getElementById("uploadPublic").value);
482
483   fetchBackend("save_track", "POST", formData,
484     function(aResult, aStatusCode) {
485       if (aStatusCode >= 400) {
486         reportUploadStatus(false, aResult);
487       }
488       else {
489         reportUploadStatus(true);
490       }
491     }
492   );
493 }
494
495 function reportUploadStatus(aSuccess, aMessage) {
496   document.getElementById("uploadStatusCloseButton").disabled = false;
497   document.getElementById("uploadInProgress").style.display = "none";
498   if (aSuccess) {
499     document.getElementById("uploadSuccess").style.display = "block";
500   }
501   else if (aMessage) {
502     document.getElementById("uploadErrorMsg").textContent = aMessage;
503     document.getElementById("uploadError").style.display = "block";
504   }
505   else {
506     document.getElementById("uploadFailed").style.display = "block";
507   }
508 }
509
510 var gPrefs = {
511   objStore: "prefs",
512
513   get: function(aKey, aCallback) {
514     if (!mainDB)
515       return;
516     var transaction = mainDB.transaction([this.objStore]);
517     var request = transaction.objectStore(this.objStore).get(aKey);
518     request.onsuccess = function(event) {
519       aCallback(request.result, event);
520     };
521     request.onerror = function(event) {
522       // Errors can be handled here.
523       aCallback(undefined, event);
524     };
525   },
526
527   set: function(aKey, aValue, aCallback) {
528     if (!mainDB)
529       return;
530     var success = false;
531     var transaction = mainDB.transaction([this.objStore], "readwrite");
532     var objStore = transaction.objectStore(this.objStore);
533     var request = objStore.put(aValue, aKey);
534     request.onsuccess = function(event) {
535       success = true;
536       if (aCallback)
537         aCallback(success, event);
538     };
539     request.onerror = function(event) {
540       // Errors can be handled here.
541       if (aCallback)
542         aCallback(success, event);
543     };
544   },
545
546   unset: function(aKey, aCallback) {
547     if (!mainDB)
548       return;
549     var success = false;
550     var transaction = mainDB.transaction([this.objStore], "readwrite");
551     var request = transaction.objectStore(this.objStore).delete(aKey);
552     request.onsuccess = function(event) {
553       success = true;
554       if (aCallback)
555         aCallback(success, event);
556     };
557     request.onerror = function(event) {
558       // Errors can be handled here.
559       if (aCallback)
560         aCallback(success, event);
561     }
562   }
563 };
564
565 var gTrackStore = {
566   objStore: "track",
567
568   getList: function(aCallback) {
569     if (!mainDB)
570       return;
571     var transaction = mainDB.transaction([this.objStore]);
572     var objStore = transaction.objectStore(this.objStore);
573     if (objStore.getAll) { // currently Mozilla-specific
574       objStore.getAll().onsuccess = function(event) {
575         aCallback(event.target.result);
576       };
577     }
578     else { // Use cursor (standard method).
579       var tPoints = [];
580       objStore.openCursor().onsuccess = function(event) {
581         var cursor = event.target.result;
582         if (cursor) {
583           tPoints.push(cursor.value);
584           cursor.continue();
585         }
586         else {
587           aCallback(tPoints);
588         }
589       };
590     }
591   },
592
593   getListStepped: function(aCallback) {
594     if (!mainDB)
595       return;
596     var transaction = mainDB.transaction([this.objStore]);
597     var objStore = transaction.objectStore(this.objStore);
598     // Use cursor in reverse direction (so we get the most recent position first)
599     objStore.openCursor(null, "prev").onsuccess = function(event) {
600       var cursor = event.target.result;
601       if (cursor) {
602         aCallback(cursor.value);
603         cursor.continue();
604       }
605       else {
606         aCallback(null);
607       }
608     };
609   },
610
611   push: function(aValue, aCallback) {
612     if (!mainDB)
613       return;
614     var transaction = mainDB.transaction([this.objStore], "readwrite");
615     var objStore = transaction.objectStore(this.objStore);
616     var request = objStore.add(aValue);
617     request.onsuccess = function(event) {
618       if (aCallback)
619         aCallback(request.result, event);
620     };
621     request.onerror = function(event) {
622       // Errors can be handled here.
623       if (aCallback)
624         aCallback(false, event);
625     };
626   },
627
628   clear: function(aCallback) {
629     if (!mainDB)
630       return;
631     var success = false;
632     var transaction = mainDB.transaction([this.objStore], "readwrite");
633     var request = transaction.objectStore(this.objStore).clear();
634     request.onsuccess = function(event) {
635       success = true;
636       if (aCallback)
637         aCallback(success, event);
638     };
639     request.onerror = function(event) {
640       // Errors can be handled here.
641       if (aCallback)
642         aCallback(success, event);
643     }
644   }
645 };
646
647 function fetchBackend(aEndpoint, aMethod, aSendData, aCallback, aCallbackForwards) {
648   var XHR = new XMLHttpRequest();
649   XHR.onreadystatechange = function() {
650     if (XHR.readyState == 4) {
651       // State says we are fully loaded.
652       var result = {};
653       if (XHR.getResponseHeader("Content-Type") == "application/json") {
654         // Got a JSON object, see if we have success.
655         try {
656           result = JSON.parse(XHR.responseText);
657         }
658         catch (e) {
659           console.log(e);
660           result = {"error": e,
661                     "message": XHR.responseText};
662         }
663       }
664       else {
665         result = XHR.responseText;
666       }
667       aCallback(result, XHR.status, aCallbackForwards);
668     }
669   };
670   XHR.open(aMethod, gBackendURL + aEndpoint, true);
671   XHR.withCredentials = "true";
672   //XHR.setRequestHeader("Accept", "application/json");
673   try {
674     XHR.send(aSendData); // Send actual form data.
675   }
676   catch (e) {
677     aCallback(e, 500, aCallbackForwards);
678   }
679 }
680
681 function getParameterByName(aName) {
682   // from http://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
683   name = aName.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
684   var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
685       results = regex.exec(location.search);
686   return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
687 }