d269a9574aec018f21894c08aeb674341852a3e5
[lantea.git] / js / map.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 var gMapCanvas, gMapContext, gTrackCanvas, gTrackContext, gGeolocation;
6 var gDebug = false;
7
8 var gTileSize = 256;
9 var gMaxZoom = 18; // The minimum is 0.
10
11 var gMinTrackAccuracy = 1000; // meters
12 var gTrackWidth = 2; // pixels
13 var gTrackColor = "#FF0000";
14 var gCurLocSize = 6; // pixels
15 var gCurLocColor = "#A00000";
16
17 var gMapStyles = {
18   // OSM tile usage policy: http://wiki.openstreetmap.org/wiki/Tile_usage_policy
19   // Find some more OSM ones at http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
20   osm_mapnik:
21     {name: "OpenStreetMap (Mapnik)",
22      url: "http://tile.openstreetmap.org/{z}/{x}/{y}.png",
23      copyright: 'Map data and imagery &copy; <a href="http://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="http://www.openstreetmap.org/copyright">ODbL/CC-BY-SA</a>'},
24   osm_cyclemap:
25     {name: "Cycle Map (OSM)",
26      url: "http://[a-c].tile.opencyclemap.org/cycle/{z}/{x}/{y}.png",
27      copyright: 'Map data and imagery &copy; <a href="http://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="http://www.openstreetmap.org/copyright">ODbL/CC-BY-SA</a>'},
28   osm_transmap:
29     {name: "Transport Map (OSM)",
30      url: "http://[a-c].tile2.opencyclemap.org/transport/{z}/{x}/{y}.png",
31      copyright: 'Map data and imagery &copy; <a href="http://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="http://www.openstreetmap.org/copyright">ODbL/CC-BY-SA</a>'},
32   mapquest_open:
33     {name: "MapQuest OSM",
34      url: "http://otile[1-4].mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png",
35      copyright: 'Map data &copy; <a href="http://www.openstreetmap.org/">OpenStreetMap</a> and contributors (<a href="http://www.openstreetmap.org/copyright">ODbL/CC-BY-SA</a>), tiles Courtesy of <a href="http://www.mapquest.com/">MapQuest</a>.'},
36   mapquest_aerial:
37     {name: "MapQuest Open Aerial",
38      url: "http://otile[1-4].mqcdn.com/tiles/1.0.0/sat/{z}/{x}/{y}.jpg",
39      copyright: 'Tiles Courtesy of <a href="http://www.mapquest.com/">MapQuest</a>, portions Courtesy NASA/JPL-Caltech and U.S. Depart. of Agriculture, Farm Service Agency.'},
40   opengeoserver_arial:
41     {name: "OpenGeoServer Aerial",
42      url: "http://services.opengeoserver.org/tiles/1.0.0/globe.aerial_EPSG3857/{z}/{x}/{y}.png?origin=nw",
43      copyright: 'Tiles by <a href="http://www.opengeoserver.org/">OpenGeoServer.org</a>, <a href="https://creativecommons.org/licenses/by/3.0/at/">CC-BY 3.0 AT</a>.'},
44   google_map:
45     {name: "Google Maps",
46      url: " http://mt1.google.com/vt/x={x}&y={y}&z={z}",
47      copyright: 'Map data and imagery &copy; <a href="http://maps.google.com/">Google</a>'},
48 };
49 var gActiveMap = "osm_mapnik";
50
51 var gPos = {x: 35630000.0, // Current position in the map in pixels at the maximum zoom level (18)
52             y: 23670000.0, // The range is 0-67108864 (2^gMaxZoom * gTileSize)
53             z: 5}; // This could be fractional if supported being between zoom levels.
54
55 var gLastMouseX = 0;
56 var gLastMouseY = 0;
57 var gZoomFactor;
58
59 var gLoadingTile;
60
61 var gMapPrefsLoaded = false;
62
63 var gDragging = false;
64 var gDragTouchID;
65
66 var gGeoWatchID;
67 var gTrack = [];
68 var gLastTrackPoint, gLastDrawnPoint;
69 var gCenterPosition = true;
70
71 var gCurPosMapCache;
72
73 function initMap() {
74   gGeolocation = navigator.geolocation;
75   gMapCanvas = document.getElementById("map");
76   gMapContext = gMapCanvas.getContext("2d");
77   gTrackCanvas = document.getElementById("track");
78   gTrackContext = gTrackCanvas.getContext("2d");
79   if (!gActiveMap)
80     gActiveMap = "osm_mapnik";
81
82   //gDebug = true;
83   if (gDebug) {
84     gGeolocation = geofake;
85     var hiddenList = document.getElementsByClassName("debugHide");
86     // last to first - list of elements with that class is changing!
87     for (var i = hiddenList.length - 1; i >= 0; i--) {
88       hiddenList[i].classList.remove("debugHide");
89     }
90   }
91
92   var loopCnt = 0;
93   var getPersistentPrefs = function() {
94     if (mainDB) {
95       gWaitCounter++;
96       gPrefs.get("position", function(aValue) {
97         if (aValue) {
98           gPos = aValue;
99           gWaitCounter--;
100         }
101       });
102       gWaitCounter++;
103       gPrefs.get("center_map", function(aValue) {
104         if (aValue === undefined)
105           document.getElementById("centerCheckbox").checked = true;
106         else
107           document.getElementById("centerCheckbox").checked = aValue;
108         setCentering(document.getElementById("centerCheckbox"));
109         gWaitCounter--;
110       });
111       gWaitCounter++;
112       gPrefs.get("tracking_enabled", function(aValue) {
113         if (aValue === undefined)
114           document.getElementById("trackCheckbox").checked = true;
115         else
116           document.getElementById("trackCheckbox").checked = aValue;
117         gWaitCounter--;
118       });
119       gWaitCounter++;
120       gTrackStore.getList(function(aTPoints) {
121         if (gDebug)
122           console.log(aTPoints.length + " points loaded.");
123         if (aTPoints.length) {
124           gTrack = aTPoints;
125         }
126         gWaitCounter--;
127       });
128     }
129     else
130       setTimeout(getPersistentPrefs, 100);
131     loopCnt++;
132     if (loopCnt > 50) {
133       console.log("Loading prefs failed.");
134     }
135   };
136   getPersistentPrefs();
137
138   gTrackCanvas.addEventListener("mouseup", mapEvHandler, false);
139   gTrackCanvas.addEventListener("mousemove", mapEvHandler, false);
140   gTrackCanvas.addEventListener("mousedown", mapEvHandler, false);
141   gTrackCanvas.addEventListener("mouseout", mapEvHandler, false);
142
143   gTrackCanvas.addEventListener("touchstart", mapEvHandler, false);
144   gTrackCanvas.addEventListener("touchmove", mapEvHandler, false);
145   gTrackCanvas.addEventListener("touchend", mapEvHandler, false);
146   gTrackCanvas.addEventListener("touchcancel", mapEvHandler, false);
147   gTrackCanvas.addEventListener("touchleave", mapEvHandler, false);
148
149   gTrackCanvas.addEventListener("wheel", mapEvHandler, false);
150
151   document.getElementById("body").addEventListener("keydown", mapEvHandler, false);
152
153   document.getElementById("copyright").innerHTML =
154       gMapStyles[gActiveMap].copyright;
155
156   gLoadingTile = new Image();
157   gLoadingTile.src = "style/loading.png";
158   gWaitCounter++;
159   gLoadingTile.onload = function() { gWaitCounter--; };
160 }
161
162 function resizeAndDraw() {
163   var viewportWidth = Math.min(window.innerWidth, window.outerWidth);
164   var viewportHeight = Math.min(window.innerHeight, window.outerHeight);
165   if (gMapCanvas && gTrackCanvas) {
166     gMapCanvas.width = viewportWidth;
167     gMapCanvas.height = viewportHeight;
168     gTrackCanvas.width = viewportWidth;
169     gTrackCanvas.height = viewportHeight;
170     drawMap();
171     showUI();
172   }
173 }
174
175 // Using scale(x, y) together with drawing old data on scaled canvas would be an improvement for zooming.
176 // See https://developer.mozilla.org/en-US/docs/Canvas_tutorial/Transformations#Scaling
177
178 function zoomIn() {
179   if (gPos.z < gMaxZoom) {
180     gPos.z++;
181     drawMap();
182   }
183 }
184
185 function zoomOut() {
186   if (gPos.z > 0) {
187     gPos.z--;
188     drawMap();
189   }
190 }
191
192 function zoomTo(aTargetLevel) {
193   aTargetLevel = parseInt(aTargetLevel);
194   if (aTargetLevel >= 0 && aTargetLevel <= gMaxZoom) {
195     gPos.z = aTargetLevel;
196     drawMap();
197   }
198 }
199
200 function gps2xy(aLatitude, aLongitude) {
201   var maxZoomFactor = Math.pow(2, gMaxZoom) * gTileSize;
202   var convLat = aLatitude * Math.PI / 180;
203   var rawY = (1 - Math.log(Math.tan(convLat) +
204                            1 / Math.cos(convLat)) / Math.PI) / 2 * maxZoomFactor;
205   var rawX = (aLongitude + 180) / 360 * maxZoomFactor;
206   return {x: Math.round(rawX),
207           y: Math.round(rawY)};
208 }
209
210 function xy2gps(aX, aY) {
211   var maxZoomFactor = Math.pow(2, gMaxZoom) * gTileSize;
212   var n = Math.PI - 2 * Math.PI * aY / maxZoomFactor;
213   return {latitude: 180 / Math.PI *
214                     Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))),
215           longitude: aX / maxZoomFactor * 360 - 180};
216 }
217
218 function setMapStyle() {
219   var mapSel = document.getElementById("mapSelector");
220   if (mapSel.selectedIndex >= 0 && gActiveMap != mapSel.value) {
221     gActiveMap = mapSel.value;
222     document.getElementById("copyright").innerHTML =
223         gMapStyles[gActiveMap].copyright;
224     showUI();
225     drawMap();
226   }
227 }
228
229 // A sane mod function that works for negative numbers.
230 // Returns a % b.
231 function mod(a, b) {
232   return ((a % b) + b) % b;
233 }
234
235 function normalizeCoords(aCoords) {
236   var zoomFactor = Math.pow(2, aCoords.z);
237   return {x: mod(aCoords.x, zoomFactor),
238           y: mod(aCoords.y, zoomFactor),
239           z: aCoords.z};
240 }
241
242 // Returns true if the tile is outside the current view.
243 function isOutsideWindow(t) {
244   var pos = decodeIndex(t);
245
246   var zoomFactor = Math.pow(2, gMaxZoom - pos.z);
247   var wid = gMapCanvas.width * zoomFactor;
248   var ht = gMapCanvas.height * zoomFactor;
249
250   pos.x *= zoomFactor;
251   pos.y *= zoomFactor;
252
253   var sz = gTileSize * zoomFactor;
254   if (pos.x > gPos.x + wid / 2 || pos.y > gPos.y + ht / 2 ||
255       pos.x + sz < gPos.x - wid / 2 || pos.y - sz < gPos.y - ht / 2)
256     return true;
257   return false;
258 }
259
260 function encodeIndex(x, y, z) {
261   var norm = normalizeCoords({x: x, y: y, z: z});
262   return norm.x + "," + norm.y + "," + norm.z;
263 }
264
265 function decodeIndex(encodedIdx) {
266   var ind = encodedIdx.split(",", 3);
267   return {x: ind[0], y: ind[1], z: ind[2]};
268 }
269
270 function drawMap(aPixels, aOverdraw) {
271   // aPixels is an object with left/right/top/bottom members telling how many
272   //   pixels on the borders should actually be drawn.
273   // aOverdraw is a bool that tells if we should draw placeholders or draw
274   //   straight over the existing content.
275   if (!aPixels)
276     aPixels = {left: gMapCanvas.width, right: gMapCanvas.width,
277                top: gMapCanvas.height, bottom: gMapCanvas.height};
278   if (!aOverdraw)
279     aOverdraw = false;
280
281   document.getElementById("zoomLevel").textContent = gPos.z;
282   gZoomFactor = Math.pow(2, gMaxZoom - gPos.z);
283   var wid = gMapCanvas.width * gZoomFactor; // Width in level 18 pixels.
284   var ht = gMapCanvas.height * gZoomFactor; // Height in level 18 pixels.
285   var size = gTileSize * gZoomFactor; // Tile size in level 18 pixels.
286
287   var xMin = gPos.x - wid / 2; // Corners of the window in level 18 pixels.
288   var yMin = gPos.y - ht / 2;
289   var xMax = gPos.x + wid / 2;
290   var yMax = gPos.y + ht / 2;
291
292   if (gMapPrefsLoaded && mainDB)
293     gPrefs.set("position", gPos);
294
295   var tiles = {left: Math.ceil((xMin + aPixels.left * gZoomFactor) / size) -
296                                (aPixels.left ? 0 : 1),
297                right: Math.floor((xMax - aPixels.right * gZoomFactor) / size) -
298                                  (aPixels.right ? 1 : 0),
299                top: Math.ceil((yMin + aPixels.top * gZoomFactor) / size) -
300                               (aPixels.top ? 0 : 1),
301                bottom: Math.floor((yMax - aPixels.bottom * gZoomFactor) / size) -
302                                   (aPixels.bottom ? 1 : 0)};
303
304   // Go through all the tiles in the map, find out if to draw them and do so.
305   for (var x = Math.floor(xMin / size); x < Math.ceil(xMax / size); x++) {
306     for (var y = Math.floor(yMin / size); y < Math.ceil(yMax / size); y++) { // slow script warnings on the tablet appear here!
307       // Only go to the drawing step if we need to draw this tile.
308       if (x < tiles.left || x > tiles.right ||
309           y < tiles.top || y > tiles.bottom) {
310         // Round here is **CRUCIAL** otherwise the images are filtered
311         // and the performance sucks (more than expected).
312         var xoff = Math.round((x * size - xMin) / gZoomFactor);
313         var yoff = Math.round((y * size - yMin) / gZoomFactor);
314         // Draw placeholder tile unless we overdraw.
315         if (!aOverdraw &&
316             (x < tiles.left -1  || x > tiles.right + 1 ||
317              y < tiles.top -1 || y > tiles.bottom + 1))
318           gMapContext.drawImage(gLoadingTile, xoff, yoff);
319
320         // Initiate loading/drawing of the actual tile.
321         gTileService.get(gActiveMap, {x: x, y: y, z: gPos.z},
322                          function(aImage, aStyle, aCoords) {
323           // Only draw if this applies for the current view.
324           if ((aStyle == gActiveMap) && (aCoords.z == gPos.z)) {
325             var ixMin = gPos.x - wid / 2;
326             var iyMin = gPos.y - ht / 2;
327             var ixoff = Math.round((aCoords.x * size - ixMin) / gZoomFactor);
328             var iyoff = Math.round((aCoords.y * size - iyMin) / gZoomFactor);
329             var URL = window.URL;
330             var imgURL = URL.createObjectURL(aImage);
331             var imgObj = new Image();
332             imgObj.src = imgURL;
333             imgObj.onload = function() {
334               gMapContext.drawImage(imgObj, ixoff, iyoff);
335               URL.revokeObjectURL(imgURL);
336             }
337           }
338         });
339       }
340     }
341   }
342   gLastDrawnPoint = null;
343   gCurPosMapCache = undefined;
344   gTrackContext.clearRect(0, 0, gTrackCanvas.width, gTrackCanvas.height);
345   if (gTrack.length) {
346     for (var i = 0; i < gTrack.length; i++) {
347       drawTrackPoint(gTrack[i].coords.latitude, gTrack[i].coords.longitude,
348                      (i + 1 >= gTrack.length));
349     }
350   }
351 }
352
353 function drawTrackPoint(aLatitude, aLongitude, lastPoint) {
354   var trackpoint = gps2xy(aLatitude, aLongitude);
355   // lastPoint is for optimizing (not actually executing the draw until the last)
356   trackpoint.optimized = (lastPoint === false);
357   var mappos = {x: Math.round((trackpoint.x - gPos.x) / gZoomFactor + gMapCanvas.width / 2),
358                 y: Math.round((trackpoint.y - gPos.y) / gZoomFactor + gMapCanvas.height / 2)};
359
360   if (!gLastDrawnPoint || !gLastDrawnPoint.optimized) {
361     gTrackContext.strokeStyle = gTrackColor;
362     gTrackContext.fillStyle = gTrackContext.strokeStyle;
363     gTrackContext.lineWidth = gTrackWidth;
364     gTrackContext.lineCap = "round";
365     gTrackContext.lineJoin = "round";
366   }
367   if (!gLastDrawnPoint || gLastDrawnPoint == trackpoint) {
368     // This breaks optimiziation, so make sure to close path and reset optimization.
369     if (gLastDrawnPoint && gLastDrawnPoint.optimized)
370       gTrackContext.stroke();
371     gTrackContext.beginPath();
372     trackpoint.optimized = false;
373     gTrackContext.arc(mappos.x, mappos.y,
374                       gTrackContext.lineWidth, 0, Math.PI * 2, false);
375     gTrackContext.fill();
376   }
377   else {
378     if (!gLastDrawnPoint || !gLastDrawnPoint.optimized) {
379       gTrackContext.beginPath();
380       gTrackContext.moveTo(Math.round((gLastDrawnPoint.x - gPos.x) / gZoomFactor + gMapCanvas.width / 2),
381                            Math.round((gLastDrawnPoint.y - gPos.y) / gZoomFactor + gMapCanvas.height / 2));
382     }
383     gTrackContext.lineTo(mappos.x, mappos.y);
384     if (!trackpoint.optimized)
385       gTrackContext.stroke();
386   }
387   gLastDrawnPoint = trackpoint;
388 }
389
390 function drawCurrentLocation(trackPoint) {
391   var locpoint = gps2xy(trackPoint.coords.latitude, trackPoint.coords.longitude);
392   var circleRadius = Math.round(gCurLocSize / 2);
393   var mappos = {x: Math.round((locpoint.x - gPos.x) / gZoomFactor + gMapCanvas.width / 2),
394                 y: Math.round((locpoint.y - gPos.y) / gZoomFactor + gMapCanvas.height / 2)};
395
396   undrawCurrentLocation();
397
398   // Cache overdrawn area.
399   gCurPosMapCache =
400       {point: locpoint,
401        radius: circleRadius,
402        data: gTrackContext.getImageData(mappos.x - circleRadius,
403                                         mappos.y - circleRadius,
404                                         circleRadius * 2, circleRadius * 2)};
405
406   gTrackContext.strokeStyle = gCurLocColor;
407   gTrackContext.fillStyle = gTrackContext.strokeStyle;
408   gTrackContext.beginPath();
409   gTrackContext.arc(mappos.x, mappos.y,
410                     circleRadius, 0, Math.PI * 2, false);
411   gTrackContext.fill();
412 }
413
414 function undrawCurrentLocation() {
415   if (gCurPosMapCache) {
416     var oldpoint = gCurPosMapCache.point;
417     var oldmp = {x: Math.round((oldpoint.x - gPos.x) / gZoomFactor + gMapCanvas.width / 2),
418                  y: Math.round((oldpoint.y - gPos.y) / gZoomFactor + gMapCanvas.height / 2)};
419     gTrackContext.putImageData(gCurPosMapCache.data,
420                                oldmp.x - gCurPosMapCache.radius,
421                                oldmp.y - gCurPosMapCache.radius);
422     gCurPosMapCache = undefined;
423   }
424 }
425
426 var mapEvHandler = {
427   handleEvent: function(aEvent) {
428     var touchEvent = aEvent.type.indexOf('touch') != -1;
429
430     // Bail out if the event is happening on an input.
431     if (aEvent.target.tagName.toLowerCase() == "input")
432       return;
433
434     // Bail out on unwanted map moves, but not zoom or keyboard events.
435     if (aEvent.type.indexOf("mouse") === 0 || aEvent.type.indexOf("touch") === 0) {
436       // Bail out if this is neither a touch nor left-click.
437       if (!touchEvent && aEvent.button != 0)
438         return;
439
440       // Bail out if the started touch can't be found.
441       if (touchEvent && gDragging &&
442           !aEvent.changedTouches.identifiedTouch(gDragTouchID))
443         return;
444     }
445
446     var coordObj = touchEvent ?
447                    aEvent.changedTouches.identifiedTouch(gDragTouchID) :
448                    aEvent;
449
450     switch (aEvent.type) {
451       case "mousedown":
452       case "touchstart":
453         if (touchEvent) {
454           gDragTouchID = aEvent.changedTouches.item(0).identifier;
455           coordObj = aEvent.changedTouches.identifiedTouch(gDragTouchID);
456         }
457         var x = coordObj.clientX - gMapCanvas.offsetLeft;
458         var y = coordObj.clientY - gMapCanvas.offsetTop;
459
460         if (touchEvent || aEvent.button === 0) {
461           gDragging = true;
462         }
463         gLastMouseX = x;
464         gLastMouseY = y;
465         showUI();
466         break;
467       case "mousemove":
468       case "touchmove":
469         var x = coordObj.clientX - gMapCanvas.offsetLeft;
470         var y = coordObj.clientY - gMapCanvas.offsetTop;
471         if (gDragging === true) {
472           var dX = x - gLastMouseX;
473           var dY = y - gLastMouseY;
474           gPos.x -= dX * gZoomFactor;
475           gPos.y -= dY * gZoomFactor;
476           if (true) { // use optimized path
477             var mapData = gMapContext.getImageData(0, 0,
478                                                    gMapCanvas.width,
479                                                    gMapCanvas.height);
480             gMapContext.clearRect(0, 0, gMapCanvas.width, gMapCanvas.height);
481             gMapContext.putImageData(mapData, dX, dY);
482             drawMap({left: (dX > 0) ? dX : 0,
483                      right: (dX < 0) ? -dX : 0,
484                      top: (dY > 0) ? dY : 0,
485                      bottom: (dY < 0) ? -dY : 0});
486           }
487           else {
488             drawMap(false, true);
489           }
490           showUI();
491         }
492         gLastMouseX = x;
493         gLastMouseY = y;
494         break;
495       case "mouseup":
496       case "touchend":
497         gDragging = false;
498         showUI();
499         break;
500       case "mouseout":
501       case "touchcancel":
502       case "touchleave":
503         //gDragging = false;
504         break;
505       case "wheel":
506         // If we'd want pixels, we'd need to calc up using aEvent.deltaMode.
507         // See https://developer.mozilla.org/en-US/docs/Mozilla_event_reference/wheel
508
509         // Only accept (non-null) deltaY values
510         if (!aEvent.deltaY)
511           break;
512
513         // Debug output: "coordinates" of the point the mouse was over.
514         /*
515         var ptCoord = {x: gPos.x + (x - gMapCanvas.width / 2) * gZoomFactor,
516                        y: gPos.y + (x - gMapCanvas.height / 2) * gZoomFactor};
517         var gpsCoord = xy2gps(ptCoord.x, ptCoord.y);
518         var pt2Coord = gps2xy(gpsCoord.latitude, gpsCoord.longitude);
519         console.log(ptCoord.x + "/" + ptCoord.y + " - " +
520                     gpsCoord.latitude + "/" + gpsCoord.longitude + " - " +
521                     pt2Coord.x + "/" + pt2Coord.y);
522         */
523
524         var newZoomLevel = gPos.z + (aEvent.deltaY < 0 ? 1 : -1);
525         if ((newZoomLevel >= 0) && (newZoomLevel <= gMaxZoom)) {
526           // Calculate new center of the map - same point stays under the mouse.
527           // This means that the pixel distance between the old center and point
528           // must equal the pixel distance of the new center and that point.
529           var x = coordObj.clientX - gMapCanvas.offsetLeft;
530           var y = coordObj.clientY - gMapCanvas.offsetTop;
531
532           // Zoom factor after this action.
533           var newZoomFactor = Math.pow(2, gMaxZoom - newZoomLevel);
534           gPos.x -= (x - gMapCanvas.width / 2) * (newZoomFactor - gZoomFactor);
535           gPos.y -= (y - gMapCanvas.height / 2) * (newZoomFactor - gZoomFactor);
536
537           if (aEvent.deltaY < 0)
538             zoomIn();
539           else
540             zoomOut();
541         }
542         break;
543       case "keydown":
544         // Allow keyboard control to move and zoom the map.
545         // Should use aEvent.key instead of aEvent.which but needs bug 680830.
546         // See https://developer.mozilla.org/en-US/docs/DOM/Mozilla_event_reference/keydown
547         var dX = 0;
548         var dY = 0;
549         switch (aEvent.which) {
550           case 39: // right
551             dX = -gTileSize / 2;
552           break;
553           case 37: // left
554             dX = gTileSize / 2;
555           break;
556           case 38: // up
557             dY = gTileSize / 2;
558           break;
559           case 40: // down
560             dY = -gTileSize / 2;
561           break;
562           case 87: // w
563           case 107: // + (numpad)
564           case 171: // + (normal key)
565             zoomIn();
566           break;
567           case 83: // s
568           case 109: // - (numpad)
569           case 173: // - (normal key)
570             zoomOut();
571           break;
572           case 48: // 0
573           case 49: // 1
574           case 50: // 2
575           case 51: // 3
576           case 52: // 4
577           case 53: // 5
578           case 54: // 6
579           case 55: // 7
580           case 56: // 8
581             zoomTo(aEvent.which - 38);
582           break;
583           case 57: // 9
584             zoomTo(9);
585           break;
586           case 96: // 0 (numpad)
587           case 97: // 1 (numpad)
588           case 98: // 2 (numpad)
589           case 99: // 3 (numpad)
590           case 100: // 4 (numpad)
591           case 101: // 5 (numpad)
592           case 102: // 6 (numpad)
593           case 103: // 7 (numpad)
594           case 104: // 8 (numpad)
595             zoomTo(aEvent.which - 86);
596           break;
597           case 105: // 9 (numpad)
598             zoomTo(9);
599           break;
600           default: // not supported
601             console.log("key not supported: " + aEvent.which);
602           break;
603         }
604
605         // Move if needed.
606         if (dX || dY) {
607           gPos.x -= dX * gZoomFactor;
608           gPos.y -= dY * gZoomFactor;
609           if (true) { // use optimized path
610             var mapData = gMapContext.getImageData(0, 0,
611                                                    gMapCanvas.width,
612                                                    gMapCanvas.height);
613             gMapContext.clearRect(0, 0, gMapCanvas.width, gMapCanvas.height);
614             gMapContext.putImageData(mapData, dX, dY);
615             drawMap({left: (dX > 0) ? dX : 0,
616                      right: (dX < 0) ? -dX : 0,
617                      top: (dY > 0) ? dY : 0,
618                      bottom: (dY < 0) ? -dY : 0});
619           }
620           else {
621             drawMap(false, true);
622           }
623         }
624         break;
625     }
626   }
627 };
628
629 var geofake = {
630   tracking: false,
631   lastPos: {x: undefined, y: undefined},
632   watchPosition: function(aSuccessCallback, aErrorCallback, aPrefObject) {
633     this.tracking = true;
634     var watchCall = function() {
635       // calc new position in lat/lon degrees
636       // 90° on Earth surface are ~10,000 km at the equator,
637       // so try moving at most 10m at a time
638       if (geofake.lastPos.x)
639         geofake.lastPos.x += (Math.random() - .5) * 90 / 1000000
640       else
641         geofake.lastPos.x = 48.208174
642       if (geofake.lastPos.y)
643         geofake.lastPos.y += (Math.random() - .5) * 90 / 1000000
644       else
645         geofake.lastPos.y = 16.373819
646       aSuccessCallback({timestamp: Date.now(),
647                         coords: {latitude: geofake.lastPos.x,
648                                  longitude: geofake.lastPos.y,
649                                  accuracy: 20}});
650       if (geofake.tracking)
651         setTimeout(watchCall, 1000);
652     };
653     setTimeout(watchCall, 1000);
654     return "foo";
655   },
656   clearWatch: function(aID) {
657     this.tracking = false;
658   }
659 }
660
661 function setCentering(aCheckbox) {
662   if (gMapPrefsLoaded && mainDB)
663     gPrefs.set("center_map", aCheckbox.checked);
664   gCenterPosition = aCheckbox.checked;
665 }
666
667 function setTracking(aCheckbox) {
668   if (gMapPrefsLoaded && mainDB)
669     gPrefs.set("tracking_enabled", aCheckbox.checked);
670   if (aCheckbox.checked)
671     startTracking();
672   else
673     endTracking();
674 }
675
676 function startTracking() {
677   if (gGeolocation) {
678     gActionLabel.textContent = "Establishing Position";
679     gAction.style.display = "block";
680     gGeoWatchID = gGeolocation.watchPosition(
681       function(position) {
682         if (gActionLabel.textContent) {
683           gActionLabel.textContent = "";
684           gAction.style.display = "none";
685         }
686         // Coords spec: https://developer.mozilla.org/en/XPCOM_Interface_Reference/NsIDOMGeoPositionCoords
687         var tPoint = {time: position.timestamp,
688                       coords: {latitude: position.coords.latitude,
689                                longitude: position.coords.longitude,
690                                altitude: position.coords.altitude,
691                                accuracy: position.coords.accuracy,
692                                altitudeAccuracy: position.coords.altitudeAccuracy,
693                                heading: position.coords.heading,
694                                speed: position.coords.speed},
695                       beginSegment: !gLastTrackPoint};
696         // Only add point to track is accuracy is good enough.
697         if (tPoint.coords.accuracy < gMinTrackAccuracy) {
698           gLastTrackPoint = tPoint;
699           gTrack.push(tPoint);
700           try { gTrackStore.push(tPoint); } catch(e) {}
701           var redrawn = false;
702           if (gCenterPosition) {
703             var posCoord = gps2xy(position.coords.latitude,
704                                   position.coords.longitude);
705             if (Math.abs(gPos.x - posCoord.x) > gMapCanvas.width * gZoomFactor / 4 ||
706                 Math.abs(gPos.y - posCoord.y) > gMapCanvas.height * gZoomFactor / 4) {
707               gPos.x = posCoord.x;
708               gPos.y = posCoord.y;
709               drawMap(); // This draws the current point as well.
710               redrawn = true;
711             }
712           }
713           if (!redrawn)
714             undrawCurrentLocation();
715             drawTrackPoint(position.coords.latitude, position.coords.longitude, true);
716         }
717         drawCurrentLocation(tPoint);
718       },
719       function(error) {
720         // Ignore erros for the moment, but this is good for debugging.
721         // See https://developer.mozilla.org/en/Using_geolocation#Handling_errors
722         if (gDebug)
723           console.log(error.message);
724       },
725       {enableHighAccuracy: true}
726     );
727   }
728 }
729
730 function endTracking() {
731   if (gActionLabel.textContent) {
732     gActionLabel.textContent = "";
733     gAction.style.display = "none";
734   }
735   if (gGeoWatchID) {
736     gGeolocation.clearWatch(gGeoWatchID);
737   }
738 }
739
740 function clearTrack() {
741   gTrack = [];
742   gTrackStore.clear();
743   drawMap({left: 0, right: 0, top: 0, bottom: 0});
744 }
745
746 var gTileService = {
747   objStore: "tilecache",
748
749   ageLimit: 14 * 86400 * 1000, // 2 weeks (in ms)
750
751   get: function(aStyle, aCoords, aCallback) {
752     var norm = normalizeCoords(aCoords);
753     var dbkey = aStyle + "::" + norm.x + "," + norm.y + "," + norm.z;
754     this.getDBCache(dbkey, function(aResult, aEvent) {
755       if (aResult) {
756         // We did get a cached object.
757         aCallback(aResult.image, aStyle, aCoords);
758         // Look at the timestamp and return if it's not too old.
759         if (aResult.timestamp + gTileService.ageLimit > Date.now())
760           return;
761         // Reload cached tile otherwise.
762         var oldDate = new Date(aResult.timestamp);
763         console.log("reload cached tile: " + dbkey + " - " + oldDate.toUTCString());
764       }
765       // Retrieve image from the web and store it in the cache.
766       var XHR = new XMLHttpRequest();
767       XHR.open("GET",
768                 gMapStyles[aStyle].url
769                   .replace("{x}", norm.x)
770                   .replace("{y}", norm.y)
771                   .replace("{z}", norm.z)
772                   .replace("[a-c]", String.fromCharCode(97 + Math.floor(Math.random() * 2)))
773                   .replace("[1-4]", 1 + Math.floor(Math.random() * 3)),
774                 true);
775       XHR.responseType = "blob";
776       XHR.addEventListener("load", function () {
777         if (XHR.status === 200) {
778           var blob = XHR.response;
779           aCallback(blob, aStyle, aCoords);
780           gTileService.setDBCache(dbkey, {image: blob, timestamp: Date.now()});
781         }
782       }, false);
783       XHR.send();
784     });
785   },
786
787   getDBCache: function(aKey, aCallback) {
788     if (!mainDB)
789       return;
790     var transaction = mainDB.transaction([this.objStore]);
791     var request = transaction.objectStore(this.objStore).get(aKey);
792     request.onsuccess = function(event) {
793       aCallback(request.result, event);
794     };
795     request.onerror = function(event) {
796       // Errors can be handled here.
797       aCallback(undefined, event);
798     };
799   },
800
801   setDBCache: function(aKey, aValue, aCallback) {
802     if (!mainDB)
803       return;
804     var success = false;
805     var transaction = mainDB.transaction([this.objStore], "readwrite");
806     var objStore = transaction.objectStore(this.objStore);
807     var request = objStore.put(aValue, aKey);
808     request.onsuccess = function(event) {
809       success = true;
810       if (aCallback)
811         aCallback(success, event);
812     };
813     request.onerror = function(event) {
814       // Errors can be handled here.
815       if (aCallback)
816         aCallback(success, event);
817     };
818   },
819
820   unsetDBCache: function(aKey, aCallback) {
821     if (!mainDB)
822       return;
823     var success = false;
824     var transaction = mainDB.transaction([this.objStore], "readwrite");
825     var request = transaction.objectStore(this.objStore).delete(aKey);
826     request.onsuccess = function(event) {
827       success = true;
828       if (aCallback)
829         aCallback(success, event);
830     };
831     request.onerror = function(event) {
832       // Errors can be handled here.
833       if (aCallback)
834         aCallback(success, event);
835     }
836   },
837
838   clearDB: function(aCallback) {
839     if (!mainDB)
840       return;
841     var success = false;
842     var transaction = mainDB.transaction([this.objStore], "readwrite");
843     var request = transaction.objectStore(this.objStore).clear();
844     request.onsuccess = function(event) {
845       success = true;
846       if (aCallback)
847         aCallback(success, event);
848     };
849     request.onerror = function(event) {
850       // Errors can be handled here.
851       if (aCallback)
852         aCallback(success, event);
853     }
854   }
855 };