persist prefs
[lantea.git] / js / map.js
1 /* ***** BEGIN LICENSE BLOCK *****
2  * Version: MPL 1.1/GPL 2.0/LGPL 2.1
3  *
4  * The contents of this file are subject to the Mozilla Public License Version
5  * 1.1 (the "License"); you may not use this file except in compliance with
6  * the License. You may obtain a copy of the License at
7  * http://www.mozilla.org/MPL/
8  *
9  * Software distributed under the License is distributed on an "AS IS" basis,
10  * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
11  * for the specific language governing rights and limitations under the
12  * License.
13  *
14  * The Original Code is Lantea mapping/tracking web app.
15  *
16  * The Initial Developer of the Original Code is
17  * Robert Kaiser <kairo@kairo.at>.
18  * Portions created by the Initial Developer are Copyright (C) 2011
19  * the Initial Developer. All Rights Reserved.
20  *
21  * Contributor(s):
22  *   Robert Kaiser <kairo@kairo.at>
23  *
24  * Alternatively, the contents of this file may be used under the terms of
25  * either the GNU General Public License Version 2 or later (the "GPL"), or
26  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
27  * in which case the provisions of the GPL or the LGPL are applicable instead
28  * of those above. If you wish to allow use of your version of this file only
29  * under the terms of either the GPL or the LGPL, and not to allow others to
30  * use your version of this file under the terms of the MPL, indicate your
31  * decision by deleting the provisions above and replace them with the notice
32  * and other provisions required by the GPL or the LGPL. If you do not delete
33  * the provisions above, a recipient may use your version of this file under
34  * the terms of any one of the MPL, the GPL or the LGPL.
35  *
36  * ***** END LICENSE BLOCK ***** */
37
38 var gCanvas, gContext;
39
40 var gTileSize = 256;
41 var gMaxZoom = 18; // The minimum is 0.
42
43 var gMapStyles = {
44   // OSM tile usage policy: http://wiki.openstreetmap.org/wiki/Tile_usage_policy
45   // Find some more OSM ones at http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
46   osm_mapnik:
47     {name: "OpenStreetMap (Mapnik)",
48      url: "http://tile.openstreetmap.org/{z}/{x}/{y}.png",
49      copyright: 'Map data and imagery &copy; <a href="http://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'},
50   osm_tilesathome:
51     {name: "OpenStreetMap (OSMarender)",
52      url: "http://tah.openstreetmap.org/Tiles/tile/{z}/{x}/{y}.png",
53      copyright: 'Map data and imagery &copy; <a href="http://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'},
54   mapquest_open:
55     {name: "MapQuest OSM",
56      url: "http://otile1.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png",
57      copyright: 'Data, imagery and map information provided by MapQuest, <a href="http://www.openstreetmap.org/">OpenStreetMap</a> and contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>.'},
58   mapquest_aerial:
59     {name: "MapQuest Open Aerial",
60      url: "http://oatile1.mqcdn.com/naip/{z}/{x}/{y}.png",
61      copyright: 'Data, imagery and map information provided by MapQuest, <a href="http://www.openstreetmap.org/">OpenStreetMap</a> and contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>.'},
62   google_map:
63     {name: "Google Maps",
64      url: " http://mt1.google.com/vt/x={x}&y={y}&z={z}",
65      copyright: 'Map data and imagery &copy; <a href="http://maps.google.com/">Google</a>'},
66 };
67 var gActiveMap = "osm_mapnik";
68
69 var gPos = {x: 35630000.0, // Current position in the map in pixels at the maximum zoom level (18)
70             y: 23670000.0, // The range is 0-67108864 (2^gMaxZoom * gTileSize)
71             z: 5}; // This could be fractional if supported being between zoom levels.
72
73 var gLastMouseX = 0;
74 var gLastMouseY = 0;
75 var gZoomFactor;
76
77 // Used as an associative array.
78 // The keys have to be strings, ours will be "xindex,yindex,zindex" e.g. "13,245,12".
79 var gTiles = {};
80 var gLoadingTile;
81
82 var gMapPrefsLoaded = false;
83
84 var gDragging = false;
85 var gZoomTouchID;
86
87 var gGeoWatchID;
88 var gTrack = [];
89 var gLastTrackPoint;
90 var gCenterPosition = true;
91
92 function initMap() {
93   gCanvas = document.getElementById("map");
94   gContext = gCanvas.getContext("2d");
95   if (!gActiveMap)
96     gActiveMap = "osm_mapnik";
97
98   var loopCnt = 0;
99   var getPersistentPrefs = function() {
100     if (mainDB) {
101       gPrefs.get("position", function(aValue) {
102         if (aValue) {
103           gPos = aValue;
104           drawMap();
105         }
106       });
107       gPrefs.get("center_map", function(aValue) {
108         if (aValue === undefined)
109           document.getElementById("centerCheckbox").checked = true;
110         else
111           document.getElementById("centerCheckbox").checked = aValue;
112         setCentering(document.getElementById("centerCheckbox"));
113       });
114       gPrefs.get("tracking_enabled", function(aValue) {
115         if (aValue === undefined)
116           document.getElementById("trackCheckbox").checked = true;
117         else
118           document.getElementById("trackCheckbox").checked = aValue;
119         setTracking(document.getElementById("trackCheckbox"));
120       });
121       gMapPrefsLoaded = true;
122     }
123     else
124       setTimeout(getPersistentPrefs, 100);
125     loopCnt++;
126     if (loopCnt > 20) {
127       gMapPrefsLoaded = true;
128       return;
129     }
130   };
131   getPersistentPrefs();
132
133   gCanvas.addEventListener("mouseup", mapEvHandler, false);
134   gCanvas.addEventListener("mousemove", mapEvHandler, false);
135   gCanvas.addEventListener("mousedown", mapEvHandler, false);
136   gCanvas.addEventListener("mouseout", mapEvHandler, false);
137
138   gCanvas.addEventListener("touchstart", mapEvHandler, false);
139   gCanvas.addEventListener("touchmove", mapEvHandler, false);
140   gCanvas.addEventListener("touchend", mapEvHandler, false);
141   gCanvas.addEventListener("touchcancel", mapEvHandler, false);
142   gCanvas.addEventListener("touchleave", mapEvHandler, false);
143
144   gCanvas.addEventListener("DOMMouseScroll", mapEvHandler, false);
145   gCanvas.addEventListener("mousewheel", mapEvHandler, false);
146
147   document.getElementById("copyright").innerHTML =
148       gMapStyles[gActiveMap].copyright;
149
150   gLoadingTile = new Image();
151   gLoadingTile.src = "style/loading.png";
152 }
153
154 function resizeAndDraw() {
155   var viewportWidth = window.innerWidth;
156   var viewportHeight = window.innerHeight;
157
158   var canvasWidth = viewportWidth * 0.98;
159   var canvasHeight = (viewportHeight - 100) * 0.98;
160   gCanvas.style.position = "fixed";
161   gCanvas.width = canvasWidth;
162   gCanvas.height = canvasHeight;
163   drawMap();
164 }
165
166 function zoomIn() {
167   if (gPos.z < gMaxZoom) {
168     gPos.z++;
169     drawMap();
170   }
171 }
172
173 function zoomOut() {
174   if (gPos.z > 0) {
175     gPos.z--;
176     drawMap();
177   }
178 }
179
180 function gps2xy(aLatitude, aLongitude) {
181   var maxZoomFactor = Math.pow(2, gMaxZoom) * gTileSize;
182   var convLat = aLatitude * Math.PI / 180;
183   var rawY = (1 - Math.log(Math.tan(convLat) +
184                            1 / Math.cos(convLat)) / Math.PI) / 2 * maxZoomFactor;
185   var rawX = (aLongitude + 180) / 360 * maxZoomFactor;
186   return {x: Math.round(rawX),
187           y: Math.round(rawY)};
188 }
189
190 function xy2gps(aX, aY) {
191   var maxZoomFactor = Math.pow(2, gMaxZoom) * gTileSize;
192   var n = Math.PI - 2 * Math.PI * aY / maxZoomFactor;
193   return {latitude: 180 / Math.PI *
194                     Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))),
195           longitude: aX / maxZoomFactor * 360 - 180};
196 }
197
198 function setMapStyle() {
199   var mapSel = document.getElementById("mapSelector");
200   if (mapSel.selectedIndex >= 0 && gActiveMap != mapSel.value) {
201     gActiveMap = mapSel.value;
202     gTiles = {};
203     drawMap();
204   }
205 }
206
207 // A sane mod function that works for negative numbers.
208 // Returns a % b.
209 function mod(a, b) {
210   return ((a % b) + b) % b;
211 }
212
213 function normaliseIndices(x, y, z) {
214   var zoomFactor = Math.pow(2, z);
215   return {x: mod(x, zoomFactor),
216           y: mod(y, zoomFactor),
217           z: z};
218 }
219
220 function tileURL(x, y, z) {
221   var norm = normaliseIndices(x, y, z);
222   return gMapStyles[gActiveMap].url.replace("{x}", norm.x)
223                                    .replace("{y}", norm.y)
224                                    .replace("{z}", norm.z);
225 }
226
227 // Returns true if the tile is outside the current view.
228 function isOutsideWindow(t) {
229   var pos = decodeIndex(t);
230   var x = pos[0];
231   var y = pos[1];
232   var z = pos[2];
233
234   var zoomFactor = Math.pow(2, gMaxZoom - z);
235   var wid = gCanvas.width * zoomFactor;
236   var ht = gCanvas.height * zoomFactor;
237
238   x *= zoomFactor;
239   y *= zoomFactor;
240
241   var sz = gTileSize * zoomFactor;
242   if (x > gPos.x + wid / 2 || y > gPos.y + ht / 2 ||
243       x + sz < gPos.x - wid / 2 || y - sz < gPos.y - ht / 2)
244     return true;
245   return false;
246 }
247
248 function encodeIndex(x, y, z) {
249   var norm = normaliseIndices(x, y, z);
250   return norm.x + "," + norm.y + "," + norm.z;
251 }
252
253 function decodeIndex(encodedIdx) {
254   return encodedIdx.split(",", 3);
255 }
256
257 function drawMap() {
258   // Go through all the currently loaded tiles. If we don't want any of them remove them.
259   // for (t in gTiles) {
260   //   if (isOutsideWindow(t))
261   //     delete gTiles[t];
262   // }
263   document.getElementById("zoomLevel").textContent = gPos.z;
264   gZoomFactor = Math.pow(2, gMaxZoom - gPos.z);
265   var wid = gCanvas.width * gZoomFactor; // Width in level 18 pixels.
266   var ht = gCanvas.height * gZoomFactor; // Height in level 18 pixels.
267   var size = gTileSize * gZoomFactor; // Tile size in level 18 pixels.
268
269   var xMin = gPos.x - wid / 2; // Corners of the window in level 18 pixels.
270   var yMin = gPos.y - ht / 2;
271   var xMax = gPos.x + wid / 2;
272   var yMax = gPos.y + ht / 2;
273
274   if (gMapPrefsLoaded && mainDB)
275     gPrefs.set("position", gPos);
276
277   // Go through all the tiles we want.
278   // If any of them aren't loaded or being loaded, do so.
279   for (var x = Math.floor(xMin / size); x < Math.ceil(xMax / size); x++) {
280     for (var y = Math.floor(yMin / size); y < Math.ceil(yMax / size); y++) {
281       var xoff = (x * size - xMin) / gZoomFactor;
282       var yoff = (y * size - yMin) / gZoomFactor;
283       var tileKey = encodeIndex(x, y, gPos.z);
284       if (gTiles[tileKey] && gTiles[tileKey].complete) {
285         // Round here is **CRUCIAL** otherwise the images are filtered
286         // and the performance sucks (more than expected).
287         gContext.drawImage(gTiles[tileKey], Math.round(xoff), Math.round(yoff));
288       }
289       else {
290         if (!gTiles[tileKey]) {
291           gTiles[tileKey] = new Image();
292           gTiles[tileKey].src = tileURL(x, y, gPos.z);
293           gTiles[tileKey].onload = function() {
294             // TODO: Just render this tile where it should be.
295             // context.drawImage(gTiles[tileKey], Math.round(xoff), Math.round(yoff)); // Doesn't work for some reason.
296             drawMap();
297           }
298         }
299         gContext.drawImage(gLoadingTile, Math.round(xoff), Math.round(yoff));
300       }
301     }
302   }
303   if (gTrack.length)
304     for (var i = 0; i < gTrack.length; i++) {
305       drawTrackPoint(gTrack[i].coords.latitude, gTrack[i].coords.longitude);
306     }
307 }
308
309 function drawTrackPoint(aLatitude, aLongitude) {
310   var trackpoint = gps2xy(aLatitude, aLongitude);
311   gContext.strokeStyle = "#FF0000";
312   gContext.fillStyle = gContext.strokeStyle;
313   gContext.lineWidth = 2;
314   gContext.lineCap = "round";
315   gContext.lineJoin = "round";
316   gContext.beginPath();
317   if (!gLastTrackPoint || gLastTrackPoint == trackpoint) {
318     gContext.arc((trackpoint.x - gPos.x) / gZoomFactor + gCanvas.width / 2,
319                  (trackpoint.y - gPos.y) / gZoomFactor + gCanvas.height / 2,
320                  gContext.lineWidth, 0, Math.PI * 2, false);
321     gContext.fill();
322   }
323   else {
324     gContext.moveTo((gLastTrackPoint.x - gPos.x) / gZoomFactor + gCanvas.width / 2,
325                     (gLastTrackPoint.y - gPos.y) / gZoomFactor + gCanvas.height / 2);
326     gContext.lineTo((trackpoint.x - gPos.x) / gZoomFactor + gCanvas.width / 2,
327                     (trackpoint.y - gPos.y) / gZoomFactor + gCanvas.height / 2);
328     gContext.stroke();
329   }
330   gLastTrackPoint = trackpoint;
331 }
332
333 var mapEvHandler = {
334   handleEvent: function(aEvent) {
335     var touchEvent = aEvent.type.indexOf('touch') != -1;
336
337     // Bail out on unwanted map moves, but not zoom-changing events.
338     if (aEvent.type != "DOMMouseScroll" && aEvent.type != "mousewheel") {
339       // Bail out if this is neither a touch nor left-click.
340       if (!touchEvent && aEvent.button != 0)
341         return;
342
343       // Bail out if the started touch can't be found.
344       if (touchEvent && zoomstart &&
345           !aEvent.changedTouches.identifiedTouch(gZoomTouchID))
346         return;
347     }
348
349     var coordObj = touchEvent ?
350                    aEvent.changedTouches.identifiedTouch(gZoomTouchID) :
351                    aEvent;
352
353     switch (aEvent.type) {
354       case "mousedown":
355       case "touchstart":
356         if (touchEvent) {
357           zoomTouchID = aEvent.changedTouches.item(0).identifier;
358           coordObj = aEvent.changedTouches.identifiedTouch(gZoomTouchID);
359         }
360         var x = coordObj.clientX - gCanvas.offsetLeft;
361         var y = coordObj.clientY - gCanvas.offsetTop;
362
363         if (touchEvent || aEvent.button === 0) {
364           gDragging = true;
365         }
366         gLastMouseX = x;
367         gLastMouseY = y;
368         break;
369       case "mousemove":
370       case "touchmove":
371         var x = coordObj.clientX - gCanvas.offsetLeft;
372         var y = coordObj.clientY - gCanvas.offsetTop;
373         if (gDragging === true) {
374           var dX = x - gLastMouseX;
375           var dY = y - gLastMouseY;
376           gPos.x -= dX * gZoomFactor;
377           gPos.y -= dY * gZoomFactor;
378           drawMap();
379         }
380         gLastMouseX = x;
381         gLastMouseY = y;
382         break;
383       case "mouseup":
384       case "touchend":
385         gDragging = false;
386         break;
387       case "mouseout":
388       case "touchcancel":
389       case "touchleave":
390         //gDragging = false;
391         break;
392       case "DOMMouseScroll":
393       case "mousewheel":
394         var delta = 0;
395         if (aEvent.wheelDelta) {
396           delta = aEvent.wheelDelta / 120;
397           if (window.opera)
398             delta = -delta;
399         }
400         else if (aEvent.detail) {
401           delta = -aEvent.detail / 3;
402         }
403
404         // Calculate new center of the map - same point stays under the mouse.
405         // This means that the pixel distance between the old center and point
406         // must equal the pixel distance of the new center and that point.
407         var x = coordObj.clientX - gCanvas.offsetLeft;
408         var y = coordObj.clientY - gCanvas.offsetTop;
409         // Debug output: "coordinates" of the point the mouse was over.
410         /*
411         var ptCoord = {x: gPos.x + (x - gCanvas.width / 2) * gZoomFactor,
412                        y: gPos.y + (x - gCanvas.height / 2) * gZoomFactor};
413         var gpsCoord = xy2gps(ptCoord.x, ptCoord.y);
414         var pt2Coord = gps2xy(gpsCoord.latitude, gpsCoord.longitude);
415         document.getElementById("debug").textContent =
416             ptCoord.x + "/" + ptCoord.y + " - " +
417             gpsCoord.latitude + "/" + gpsCoord.longitude + " - " +
418             pt2Coord.x + "/" + pt2Coord.y;
419         */
420         // Zoom factor after this action.
421         var newZoomFactor = Math.pow(2, gMaxZoom - gPos.z + (delta > 0 ? -1 : 1));
422         gPos.x -= (x - gCanvas.width / 2) * (newZoomFactor - gZoomFactor);
423         gPos.y -= (y - gCanvas.height / 2) * (newZoomFactor - gZoomFactor);
424
425         if (delta > 0)
426           zoomIn();
427         else if (delta < 0)
428           zoomOut();
429         break;
430     }
431   }
432 };
433
434 var geofake = {
435   tracking: false,
436   watchPosition: function(aSuccessCallback, aErrorCallback, aPrefObject) {
437     this.tracking = true;
438     var watchCall = function() {
439       aSuccessCallback({timestamp: Date.now(),
440                         coords: {latitude: 48.208174 +
441                                            (Math.random() - .5) / 5,
442                                  longitude: 16.373819 +
443                                             (Math.random() - .5) / 5,
444                                  accuracy: 20}});
445       if (geofake.tracking)
446         setTimeout(watchCall, 1000);
447     };
448     setTimeout(watchCall, 1000);
449     return "foo";
450   },
451   clearWatch: function(aID) {
452     this.tracking = false;
453   }
454 }
455
456 function setCentering(aCheckbox) {
457   if (gMapPrefsLoaded && mainDB)
458     gPrefs.set("center_map", aCheckbox.checked);
459   gCenterPosition = aCheckbox.checked;
460 }
461
462 function setTracking(aCheckbox) {
463   if (gMapPrefsLoaded && mainDB)
464     gPrefs.set("tracking_enabled", aCheckbox.checked);
465   if (aCheckbox.checked)
466     startTracking();
467   else
468     endTracking();
469 }
470
471 function startTracking() {
472   var loopCnt = 0;
473   var getStoredTrack = function() {
474     if (mainDB)
475       gTrackStore.getList(function(aTPoints) {
476         //document.getElementById("debug").textContent = aTPoints.length + " points loaded.";
477         if (aTPoints.length) {
478           gTrack = aTPoints;
479         }
480       });
481     else
482       setTimeout(getStoredTrack, 100);
483     loopCnt++;
484     if (loopCnt > 20)
485       return;
486   };
487   getStoredTrack();
488   if (navigator.geolocation) {
489     //gGeoWatchID = geofake.watchPosition(
490     gGeoWatchID = navigator.geolocation.watchPosition(
491       function(position) {
492         // Coords spec: https://developer.mozilla.org/en/XPCOM_Interface_Reference/NsIDOMGeoPositionCoords
493         var tPoint = {time: position.timestamp,
494                       coords: position.coords,
495                       beginSegment: !gLastTrackPoint};
496         gTrack.push(tPoint);
497         gTrackStore.push(tPoint);
498         drawTrackPoint(position.coords.latitude, position.coords.longitude);
499         if (gCenterPosition) {
500           var posCoord = gps2xy(position.coords.latitude,
501                                 position.coords.longitude);
502           if (Math.abs(gPos.x - posCoord.x) > gCanvas.width * gZoomFactor / 4 ||
503               Math.abs(gPos.y - posCoord.y) > gCanvas.height * gZoomFactor / 4) {
504             gPos.x = posCoord.x;
505             gPos.y = posCoord.y;
506             drawMap();
507           }
508         }
509       },
510       function(error) {
511         // Ignore erros for the moment, but this is good for debugging.
512         // See https://developer.mozilla.org/en/Using_geolocation#Handling_errors
513         document.getElementById("debug").textContent = error.message;
514       },
515       {enableHighAccuracy: true}
516     );
517   }
518 }
519
520 function endTracking() {
521   if (gGeoWatchID) {
522     //geofake.clearWatch(gGeoWatchID);
523     navigator.geolocation.clearWatch(gGeoWatchID);
524   }
525 }
526
527 function clearTrack() {
528   gTrack = [];
529   gTrackStore.clear();
530   drawMap();
531 }