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