bump appcache for recent changes
[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 on unwanted map moves, but not zoom or keyboard events.
431     if (aEvent.type.indexOf("mouse") === 0 || aEvent.type.indexOf("touch") === 0) {
432       // Bail out if this is neither a touch nor left-click.
433       if (!touchEvent && aEvent.button != 0)
434         return;
435
436       // Bail out if the started touch can't be found.
437       if (touchEvent && gDragging &&
438           !aEvent.changedTouches.identifiedTouch(gDragTouchID))
439         return;
440     }
441
442     var coordObj = touchEvent ?
443                    aEvent.changedTouches.identifiedTouch(gDragTouchID) :
444                    aEvent;
445
446     switch (aEvent.type) {
447       case "mousedown":
448       case "touchstart":
449         if (touchEvent) {
450           gDragTouchID = aEvent.changedTouches.item(0).identifier;
451           coordObj = aEvent.changedTouches.identifiedTouch(gDragTouchID);
452         }
453         var x = coordObj.clientX - gMapCanvas.offsetLeft;
454         var y = coordObj.clientY - gMapCanvas.offsetTop;
455
456         if (touchEvent || aEvent.button === 0) {
457           gDragging = true;
458         }
459         gLastMouseX = x;
460         gLastMouseY = y;
461         showUI();
462         break;
463       case "mousemove":
464       case "touchmove":
465         var x = coordObj.clientX - gMapCanvas.offsetLeft;
466         var y = coordObj.clientY - gMapCanvas.offsetTop;
467         if (gDragging === true) {
468           var dX = x - gLastMouseX;
469           var dY = y - gLastMouseY;
470           gPos.x -= dX * gZoomFactor;
471           gPos.y -= dY * gZoomFactor;
472           if (true) { // use optimized path
473             var mapData = gMapContext.getImageData(0, 0,
474                                                    gMapCanvas.width,
475                                                    gMapCanvas.height);
476             gMapContext.clearRect(0, 0, gMapCanvas.width, gMapCanvas.height);
477             gMapContext.putImageData(mapData, dX, dY);
478             drawMap({left: (dX > 0) ? dX : 0,
479                      right: (dX < 0) ? -dX : 0,
480                      top: (dY > 0) ? dY : 0,
481                      bottom: (dY < 0) ? -dY : 0});
482           }
483           else {
484             drawMap(false, true);
485           }
486           showUI();
487         }
488         gLastMouseX = x;
489         gLastMouseY = y;
490         break;
491       case "mouseup":
492       case "touchend":
493         gDragging = false;
494         showUI();
495         break;
496       case "mouseout":
497       case "touchcancel":
498       case "touchleave":
499         //gDragging = false;
500         break;
501       case "wheel":
502         // If we'd want pixels, we'd need to calc up using aEvent.deltaMode.
503         // See https://developer.mozilla.org/en-US/docs/Mozilla_event_reference/wheel
504
505         // Only accept (non-null) deltaY values
506         if (!aEvent.deltaY)
507           break;
508
509         // Debug output: "coordinates" of the point the mouse was over.
510         /*
511         var ptCoord = {x: gPos.x + (x - gMapCanvas.width / 2) * gZoomFactor,
512                        y: gPos.y + (x - gMapCanvas.height / 2) * gZoomFactor};
513         var gpsCoord = xy2gps(ptCoord.x, ptCoord.y);
514         var pt2Coord = gps2xy(gpsCoord.latitude, gpsCoord.longitude);
515         console.log(ptCoord.x + "/" + ptCoord.y + " - " +
516                     gpsCoord.latitude + "/" + gpsCoord.longitude + " - " +
517                     pt2Coord.x + "/" + pt2Coord.y);
518         */
519
520         var newZoomLevel = gPos.z + (aEvent.deltaY < 0 ? 1 : -1);
521         if ((newZoomLevel >= 0) && (newZoomLevel <= gMaxZoom)) {
522           // Calculate new center of the map - same point stays under the mouse.
523           // This means that the pixel distance between the old center and point
524           // must equal the pixel distance of the new center and that point.
525           var x = coordObj.clientX - gMapCanvas.offsetLeft;
526           var y = coordObj.clientY - gMapCanvas.offsetTop;
527
528           // Zoom factor after this action.
529           var newZoomFactor = Math.pow(2, gMaxZoom - newZoomLevel);
530           gPos.x -= (x - gMapCanvas.width / 2) * (newZoomFactor - gZoomFactor);
531           gPos.y -= (y - gMapCanvas.height / 2) * (newZoomFactor - gZoomFactor);
532
533           if (aEvent.deltaY < 0)
534             zoomIn();
535           else
536             zoomOut();
537         }
538         break;
539       case "keydown":
540         // Allow keyboard control to move and zoom the map.
541         // Should use aEvent.key instead of aEvent.which but needs bug 680830.
542         // See https://developer.mozilla.org/en-US/docs/DOM/Mozilla_event_reference/keydown
543         var dX = 0;
544         var dY = 0;
545         switch (aEvent.which) {
546           case 39: // right
547             dX = -gTileSize / 2;
548           break;
549           case 37: // left
550             dX = gTileSize / 2;
551           break;
552           case 38: // up
553             dY = gTileSize / 2;
554           break;
555           case 40: // down
556             dY = -gTileSize / 2;
557           break;
558           case 87: // w
559           case 107: // + (numpad)
560           case 171: // + (normal key)
561             zoomIn();
562           break;
563           case 83: // s
564           case 109: // - (numpad)
565           case 173: // - (normal key)
566             zoomOut();
567           break;
568           case 48: // 0
569           case 49: // 1
570           case 50: // 2
571           case 51: // 3
572           case 52: // 4
573           case 53: // 5
574           case 54: // 6
575           case 55: // 7
576           case 56: // 8
577             zoomTo(aEvent.which - 38);
578           break;
579           case 57: // 9
580             zoomTo(9);
581           break;
582           case 96: // 0 (numpad)
583           case 97: // 1 (numpad)
584           case 98: // 2 (numpad)
585           case 99: // 3 (numpad)
586           case 100: // 4 (numpad)
587           case 101: // 5 (numpad)
588           case 102: // 6 (numpad)
589           case 103: // 7 (numpad)
590           case 104: // 8 (numpad)
591             zoomTo(aEvent.which - 86);
592           break;
593           case 105: // 9 (numpad)
594             zoomTo(9);
595           break;
596           default: // not supported
597             console.log("key not supported: " + aEvent.which);
598           break;
599         }
600
601         // Move if needed.
602         if (dX || dY) {
603           gPos.x -= dX * gZoomFactor;
604           gPos.y -= dY * gZoomFactor;
605           if (true) { // use optimized path
606             var mapData = gMapContext.getImageData(0, 0,
607                                                    gMapCanvas.width,
608                                                    gMapCanvas.height);
609             gMapContext.clearRect(0, 0, gMapCanvas.width, gMapCanvas.height);
610             gMapContext.putImageData(mapData, dX, dY);
611             drawMap({left: (dX > 0) ? dX : 0,
612                      right: (dX < 0) ? -dX : 0,
613                      top: (dY > 0) ? dY : 0,
614                      bottom: (dY < 0) ? -dY : 0});
615           }
616           else {
617             drawMap(false, true);
618           }
619         }
620         break;
621     }
622   }
623 };
624
625 var geofake = {
626   tracking: false,
627   lastPos: {x: undefined, y: undefined},
628   watchPosition: function(aSuccessCallback, aErrorCallback, aPrefObject) {
629     this.tracking = true;
630     var watchCall = function() {
631       // calc new position in lat/lon degrees
632       // 90° on Earth surface are ~10,000 km at the equator,
633       // so try moving at most 10m at a time
634       if (geofake.lastPos.x)
635         geofake.lastPos.x += (Math.random() - .5) * 90 / 1000000
636       else
637         geofake.lastPos.x = 48.208174
638       if (geofake.lastPos.y)
639         geofake.lastPos.y += (Math.random() - .5) * 90 / 1000000
640       else
641         geofake.lastPos.y = 16.373819
642       aSuccessCallback({timestamp: Date.now(),
643                         coords: {latitude: geofake.lastPos.x,
644                                  longitude: geofake.lastPos.y,
645                                  accuracy: 20}});
646       if (geofake.tracking)
647         setTimeout(watchCall, 1000);
648     };
649     setTimeout(watchCall, 1000);
650     return "foo";
651   },
652   clearWatch: function(aID) {
653     this.tracking = false;
654   }
655 }
656
657 function setCentering(aCheckbox) {
658   if (gMapPrefsLoaded && mainDB)
659     gPrefs.set("center_map", aCheckbox.checked);
660   gCenterPosition = aCheckbox.checked;
661 }
662
663 function setTracking(aCheckbox) {
664   if (gMapPrefsLoaded && mainDB)
665     gPrefs.set("tracking_enabled", aCheckbox.checked);
666   if (aCheckbox.checked)
667     startTracking();
668   else
669     endTracking();
670 }
671
672 function startTracking() {
673   if (gGeolocation) {
674     gActionLabel.textContent = "Establishing Position";
675     gAction.style.display = "block";
676     gGeoWatchID = gGeolocation.watchPosition(
677       function(position) {
678         if (gActionLabel.textContent) {
679           gActionLabel.textContent = "";
680           gAction.style.display = "none";
681         }
682         // Coords spec: https://developer.mozilla.org/en/XPCOM_Interface_Reference/NsIDOMGeoPositionCoords
683         var tPoint = {time: position.timestamp,
684                       coords: {latitude: position.coords.latitude,
685                                longitude: position.coords.longitude,
686                                altitude: position.coords.altitude,
687                                accuracy: position.coords.accuracy,
688                                altitudeAccuracy: position.coords.altitudeAccuracy,
689                                heading: position.coords.heading,
690                                speed: position.coords.speed},
691                       beginSegment: !gLastTrackPoint};
692         // Only add point to track is accuracy is good enough.
693         if (tPoint.coords.accuracy < gMinTrackAccuracy) {
694           gLastTrackPoint = tPoint;
695           gTrack.push(tPoint);
696           try { gTrackStore.push(tPoint); } catch(e) {}
697           var redrawn = false;
698           if (gCenterPosition) {
699             var posCoord = gps2xy(position.coords.latitude,
700                                   position.coords.longitude);
701             if (Math.abs(gPos.x - posCoord.x) > gMapCanvas.width * gZoomFactor / 4 ||
702                 Math.abs(gPos.y - posCoord.y) > gMapCanvas.height * gZoomFactor / 4) {
703               gPos.x = posCoord.x;
704               gPos.y = posCoord.y;
705               drawMap(); // This draws the current point as well.
706               redrawn = true;
707             }
708           }
709           if (!redrawn)
710             undrawCurrentLocation();
711             drawTrackPoint(position.coords.latitude, position.coords.longitude, true);
712         }
713         drawCurrentLocation(tPoint);
714       },
715       function(error) {
716         // Ignore erros for the moment, but this is good for debugging.
717         // See https://developer.mozilla.org/en/Using_geolocation#Handling_errors
718         if (gDebug)
719           console.log(error.message);
720       },
721       {enableHighAccuracy: true}
722     );
723   }
724 }
725
726 function endTracking() {
727   if (gActionLabel.textContent) {
728     gActionLabel.textContent = "";
729     gAction.style.display = "none";
730   }
731   if (gGeoWatchID) {
732     gGeolocation.clearWatch(gGeoWatchID);
733   }
734 }
735
736 function clearTrack() {
737   gTrack = [];
738   gTrackStore.clear();
739   drawMap({left: 0, right: 0, top: 0, bottom: 0});
740 }
741
742 var gTileService = {
743   objStore: "tilecache",
744
745   ageLimit: 14 * 86400 * 1000, // 2 weeks (in ms)
746
747   get: function(aStyle, aCoords, aCallback) {
748     var norm = normalizeCoords(aCoords);
749     var dbkey = aStyle + "::" + norm.x + "," + norm.y + "," + norm.z;
750     this.getDBCache(dbkey, function(aResult, aEvent) {
751       if (aResult) {
752         // We did get a cached object.
753         aCallback(aResult.image, aStyle, aCoords);
754         // Look at the timestamp and return if it's not too old.
755         if (aResult.timestamp + gTileService.ageLimit > Date.now())
756           return;
757         // Reload cached tile otherwise.
758         var oldDate = new Date(aResult.timestamp);
759         console.log("reload cached tile: " + dbkey + " - " + oldDate.toUTCString());
760       }
761       // Retrieve image from the web and store it in the cache.
762       var XHR = new XMLHttpRequest();
763       XHR.open("GET",
764                 gMapStyles[aStyle].url
765                   .replace("{x}", norm.x)
766                   .replace("{y}", norm.y)
767                   .replace("{z}", norm.z)
768                   .replace("[a-c]", String.fromCharCode(97 + Math.floor(Math.random() * 2)))
769                   .replace("[1-4]", 1 + Math.floor(Math.random() * 3)),
770                 true);
771       XHR.responseType = "blob";
772       XHR.addEventListener("load", function () {
773         if (XHR.status === 200) {
774           var blob = XHR.response;
775           aCallback(blob, aStyle, aCoords);
776           gTileService.setDBCache(dbkey, {image: blob, timestamp: Date.now()});
777         }
778       }, false);
779       XHR.send();
780     });
781   },
782
783   getDBCache: function(aKey, aCallback) {
784     if (!mainDB)
785       return;
786     var transaction = mainDB.transaction([this.objStore]);
787     var request = transaction.objectStore(this.objStore).get(aKey);
788     request.onsuccess = function(event) {
789       aCallback(request.result, event);
790     };
791     request.onerror = function(event) {
792       // Errors can be handled here.
793       aCallback(undefined, event);
794     };
795   },
796
797   setDBCache: function(aKey, aValue, aCallback) {
798     if (!mainDB)
799       return;
800     var success = false;
801     var transaction = mainDB.transaction([this.objStore], "readwrite");
802     var objStore = transaction.objectStore(this.objStore);
803     var request = objStore.put(aValue, aKey);
804     request.onsuccess = function(event) {
805       success = true;
806       if (aCallback)
807         aCallback(success, event);
808     };
809     request.onerror = function(event) {
810       // Errors can be handled here.
811       if (aCallback)
812         aCallback(success, event);
813     };
814   },
815
816   unsetDBCache: function(aKey, aCallback) {
817     if (!mainDB)
818       return;
819     var success = false;
820     var transaction = mainDB.transaction([this.objStore], "readwrite");
821     var request = transaction.objectStore(this.objStore).delete(aKey);
822     request.onsuccess = function(event) {
823       success = true;
824       if (aCallback)
825         aCallback(success, event);
826     };
827     request.onerror = function(event) {
828       // Errors can be handled here.
829       if (aCallback)
830         aCallback(success, event);
831     }
832   },
833
834   clearDB: function(aCallback) {
835     if (!mainDB)
836       return;
837     var success = false;
838     var transaction = mainDB.transaction([this.objStore], "readwrite");
839     var request = transaction.objectStore(this.objStore).clear();
840     request.onsuccess = function(event) {
841       success = true;
842       if (aCallback)
843         aCallback(success, event);
844     };
845     request.onerror = function(event) {
846       // Errors can be handled here.
847       if (aCallback)
848         aCallback(success, event);
849     }
850   }
851 };