add support for persistent data via indexedDB, persist tracks across sessions using...
[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. They keys have to be strings, ours will be "xindex,yindex,zindex" e.g. "13,245,12".
78 var gTiles = {};
79 var gLoadingTile;
80
81 var gDragging = false;
82 var gZoomTouchID;
83
84 var gGeoWatchID;
85 var gTrack = [];
86 var gLastTrackPoint;
87 var gCenterPosition = true;
88
89 function initMap() {
90   gCanvas = document.getElementById("map");
91   gContext = gCanvas.getContext("2d");
92   if (!gActiveMap)
93     gActiveMap = "osm_mapnik";
94
95   gCanvas.addEventListener("mouseup", mapEvHandler, false);
96   gCanvas.addEventListener("mousemove", mapEvHandler, false);
97   gCanvas.addEventListener("mousedown", mapEvHandler, false);
98   gCanvas.addEventListener("mouseout", mapEvHandler, false);
99
100   gCanvas.addEventListener("touchstart", mapEvHandler, false);
101   gCanvas.addEventListener("touchmove", mapEvHandler, false);
102   gCanvas.addEventListener("touchend", mapEvHandler, false);
103   gCanvas.addEventListener("touchcancel", mapEvHandler, false);
104   gCanvas.addEventListener("touchleave", mapEvHandler, false);
105
106   gCanvas.addEventListener("DOMMouseScroll", mapEvHandler, false);
107   gCanvas.addEventListener("mousewheel", mapEvHandler, false);
108
109   document.getElementById("copyright").innerHTML =
110       gMapStyles[gActiveMap].copyright;
111
112   gLoadingTile = new Image();
113   gLoadingTile.src = "style/loading.png";
114 }
115
116 function resizeAndDraw() {
117   var viewportWidth = window.innerWidth;
118   var viewportHeight = window.innerHeight;
119
120   var canvasWidth = viewportWidth * 0.98;
121   var canvasHeight = (viewportHeight - 100) * 0.98;
122   gCanvas.style.position = "fixed";
123   gCanvas.width = canvasWidth;
124   gCanvas.height = canvasHeight;
125   drawMap();
126 }
127
128 function zoomIn() {
129   if (gPos.z < gMaxZoom) {
130     gPos.z++;
131     drawMap();
132   }
133 }
134
135 function zoomOut() {
136   if (gPos.z > 0) {
137     gPos.z--;
138     drawMap();
139   }
140 }
141
142 function gps2xy(aLatitude, aLongitude) {
143   var maxZoomFactor = Math.pow(2, gMaxZoom) * gTileSize;
144   var convLat = aLatitude * Math.PI / 180;
145   var rawY = (1 - Math.log(Math.tan(convLat) +
146                            1 / Math.cos(convLat)) / Math.PI) / 2 * maxZoomFactor;
147   var rawX = (aLongitude + 180) / 360 * maxZoomFactor;
148   return {x: Math.round(rawX),
149           y: Math.round(rawY)};
150 }
151
152 function xy2gps(aX, aY) {
153   var maxZoomFactor = Math.pow(2, gMaxZoom) * gTileSize;
154   var n = Math.PI - 2 * Math.PI * aY / maxZoomFactor;
155   return {latitude: 180 / Math.PI *
156                     Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))),
157           longitude: aX / maxZoomFactor * 360 - 180};
158 }
159
160 function setMapStyle() {
161   var mapSel = document.getElementById("mapSelector");
162   if (mapSel.selectedIndex >= 0 && gActiveMap != mapSel.value) {
163     gActiveMap = mapSel.value;
164     gTiles = {};
165     drawMap();
166   }
167 }
168
169 // A sane mod function that works for negative numbers.
170 // Returns a % b.
171 function mod(a, b) {
172   return ((a % b) + b) % b;
173 }
174
175 function normaliseIndices(x, y, z) {
176   var zoomFactor = Math.pow(2, z);
177   return {x: mod(x, zoomFactor),
178           y: mod(y, zoomFactor),
179           z: z};
180 }
181
182 function tileURL(x, y, z) {
183   var norm = normaliseIndices(x, y, z);
184   return gMapStyles[gActiveMap].url.replace("{x}", norm.x)
185                                    .replace("{y}", norm.y)
186                                    .replace("{z}", norm.z);
187 }
188
189 // Returns true if the tile is outside the current view.
190 function isOutsideWindow(t) {
191   var pos = decodeIndex(t);
192   var x = pos[0];
193   var y = pos[1];
194   var z = pos[2];
195
196   var zoomFactor = Math.pow(2, gMaxZoom - z);
197   var wid = gCanvas.width * zoomFactor;
198   var ht = gCanvas.height * zoomFactor;
199
200   x *= zoomFactor;
201   y *= zoomFactor;
202
203   var sz = gTileSize * zoomFactor;
204   if (x > gPos.x + wid / 2 || y > gPos.y + ht / 2 ||
205       x + sz < gPos.x - wid / 2 || y - sz < gPos.y - ht / 2)
206     return true;
207   return false;
208 }
209
210 function encodeIndex(x, y, z) {
211   var norm = normaliseIndices(x, y, z);
212   return norm.x + "," + norm.y + "," + norm.z;
213 }
214
215 function decodeIndex(encodedIdx) {
216   return encodedIdx.split(",", 3);
217 }
218
219 function drawMap() {
220   // Go through all the currently loaded tiles. If we don't want any of them remove them.
221   // for (t in gTiles) {
222   //   if (isOutsideWindow(t))
223   //     delete gTiles[t];
224   // }
225   document.getElementById("zoomLevel").textContent = gPos.z;
226   gZoomFactor = Math.pow(2, gMaxZoom - gPos.z);
227   var wid = gCanvas.width * gZoomFactor; // Width in level 18 pixels.
228   var ht = gCanvas.height * gZoomFactor; // Height in level 18 pixels.
229   var size = gTileSize * gZoomFactor; // Tile size in level 18 pixels.
230
231   var xMin = gPos.x - wid / 2; // Corners of the window in level 18 pixels.
232   var yMin = gPos.y - ht / 2;
233   var xMax = gPos.x + wid / 2;
234   var yMax = gPos.y + ht / 2;
235
236   // Go through all the tiles we want. If any of them aren't loaded or being loaded, do so.
237   for (var x = Math.floor(xMin / size); x < Math.ceil(xMax / size); x++) {
238     for (var y = Math.floor(yMin / size); y < Math.ceil(yMax / size); y++) {
239       var xoff = (x * size - xMin) / gZoomFactor;
240       var yoff = (y * size - yMin) / gZoomFactor;
241       var tileKey = encodeIndex(x, y, gPos.z);
242       if (gTiles[tileKey] && gTiles[tileKey].complete) {
243         // Round here is **CRUICIAL** otherwise the images are filtered and the performance sucks (more than expected).
244         gContext.drawImage(gTiles[tileKey], Math.round(xoff), Math.round(yoff));
245       }
246       else {
247         if (!gTiles[tileKey]) {
248           gTiles[tileKey] = new Image();
249           gTiles[tileKey].src = tileURL(x, y, gPos.z);
250           gTiles[tileKey].onload = function() {
251             // TODO: Just render this tile where it should be.
252             // context.drawImage(gTiles[tileKey], Math.round(xoff), Math.round(yoff)); // Doesn't work for some reason.
253             drawMap();
254           }
255         }
256         gContext.drawImage(gLoadingTile, Math.round(xoff), Math.round(yoff));
257       }
258     }
259   }
260   if (gTrack.length)
261     for (var i = 0; i < gTrack.length; i++) {
262       drawTrackPoint(gTrack[i].coords.latitude, gTrack[i].coords.longitude);
263     }
264 }
265
266 function drawTrackPoint(aLatitude, aLongitude) {
267   var trackpoint = gps2xy(aLatitude, aLongitude);
268   gContext.strokeStyle = "#FF0000";
269   gContext.fillStyle = gContext.strokeStyle;
270   gContext.lineWidth = 2;
271   gContext.lineCap = "round";
272   gContext.lineJoin = "round";
273   gContext.beginPath();
274   if (!gLastTrackPoint || gLastTrackPoint == trackpoint) {
275     gContext.arc((trackpoint.x - gPos.x) / gZoomFactor + gCanvas.width / 2,
276                  (trackpoint.y - gPos.y) / gZoomFactor + gCanvas.height / 2,
277                  gContext.lineWidth, 0, Math.PI * 2, false);
278     gContext.fill();
279   }
280   else {
281     gContext.moveTo((gLastTrackPoint.x - gPos.x) / gZoomFactor + gCanvas.width / 2,
282                     (gLastTrackPoint.y - gPos.y) / gZoomFactor + gCanvas.height / 2);
283     gContext.lineTo((trackpoint.x - gPos.x) / gZoomFactor + gCanvas.width / 2,
284                     (trackpoint.y - gPos.y) / gZoomFactor + gCanvas.height / 2);
285     gContext.stroke();
286   }
287   gLastTrackPoint = trackpoint;
288 }
289
290 var mapEvHandler = {
291   handleEvent: function(aEvent) {
292     var touchEvent = aEvent.type.indexOf('touch') != -1;
293
294     // Bail out on unwanted map moves, but not zoom-changing events.
295     if (aEvent.type != "DOMMouseScroll" && aEvent.type != "mousewheel") {
296       // Bail out if this is neither a touch nor left-click.
297       if (!touchEvent && aEvent.button != 0)
298         return;
299
300       // Bail out if the started touch can't be found.
301       if (touchEvent && zoomstart &&
302           !aEvent.changedTouches.identifiedTouch(gZoomTouchID))
303         return;
304     }
305
306     var coordObj = touchEvent ?
307                    aEvent.changedTouches.identifiedTouch(gZoomTouchID) :
308                    aEvent;
309
310     switch (aEvent.type) {
311       case "mousedown":
312       case "touchstart":
313         if (touchEvent) {
314           zoomTouchID = aEvent.changedTouches.item(0).identifier;
315           coordObj = aEvent.changedTouches.identifiedTouch(gZoomTouchID);
316         }
317         var x = coordObj.clientX - gCanvas.offsetLeft;
318         var y = coordObj.clientY - gCanvas.offsetTop;
319
320         if (touchEvent || aEvent.button === 0) {
321           gDragging = true;
322         }
323         gLastMouseX = x;
324         gLastMouseY = y;
325         break;
326       case "mousemove":
327       case "touchmove":
328         var x = coordObj.clientX - gCanvas.offsetLeft;
329         var y = coordObj.clientY - gCanvas.offsetTop;
330         if (gDragging === true) {
331           var dX = x - gLastMouseX;
332           var dY = y - gLastMouseY;
333           gPos.x -= dX * gZoomFactor;
334           gPos.y -= dY * gZoomFactor;
335           drawMap();
336         }
337         gLastMouseX = x;
338         gLastMouseY = y;
339         break;
340       case "mouseup":
341       case "touchend":
342         gDragging = false;
343         break;
344       case "mouseout":
345       case "touchcancel":
346       case "touchleave":
347         //gDragging = false;
348         break;
349       case "DOMMouseScroll":
350       case "mousewheel":
351         var delta = 0;
352         if (aEvent.wheelDelta) {
353           delta = aEvent.wheelDelta / 120;
354           if (window.opera)
355             delta = -delta;
356         }
357         else if (aEvent.detail) {
358           delta = -aEvent.detail / 3;
359         }
360
361         // Calculate new center of the map - same point stays under the mouse.
362         // This means that the pixel distance between the old center and point
363         // must equal the pixel distance of the new center and that point.
364         var x = coordObj.clientX - gCanvas.offsetLeft;
365         var y = coordObj.clientY - gCanvas.offsetTop;
366         // Debug output: "coordinates" of the point the mouse was over.
367         /*
368         var ptCoord = {x: gPos.x + (x - gCanvas.width / 2) * gZoomFactor,
369                        y: gPos.y + (x - gCanvas.height / 2) * gZoomFactor};
370         var gpsCoord = xy2gps(ptCoord.x, ptCoord.y);
371         var pt2Coord = gps2xy(gpsCoord.latitude, gpsCoord.longitude);
372         document.getElementById("debug").textContent =
373             ptCoord.x + "/" + ptCoord.y + " - " +
374             gpsCoord.latitude + "/" + gpsCoord.longitude + " - " +
375             pt2Coord.x + "/" + pt2Coord.y;
376         */
377         // Zoom factor after this action.
378         var newZoomFactor = Math.pow(2, gMaxZoom - gPos.z + (delta > 0 ? -1 : 1));
379         gPos.x -= (x - gCanvas.width / 2) * (newZoomFactor - gZoomFactor);
380         gPos.y -= (y - gCanvas.height / 2) * (newZoomFactor - gZoomFactor);
381
382         if (delta > 0)
383           zoomIn();
384         else if (delta < 0)
385           zoomOut();
386         break;
387     }
388   }
389 };
390
391 var geofake = {
392   tracking: false,
393   watchPosition: function(aSuccessCallback, aErrorCallback, aPrefObject) {
394     this.tracking = true;
395     var watchCall = function() {
396       aSuccessCallback({timestamp: Date.now(),
397                         coords: {latitude: 48.208174, // + Math.random() - .5,
398                                  longitude: 16.373819, // + Math.random() - .5,
399                                  accuracy: 20}});
400       if (geofake.tracking)
401         setTimeout(watchCall, 1000);
402     };
403     setTimeout(watchCall, 1000);
404     return "foo";
405   },
406   clearWatch: function(aID) {
407     this.tracking = false;
408   }
409 }
410
411 function startTracking() {
412   var loopCnt = 0;
413   var getStoredTrack = function() {
414     if (mainDB)
415       gTrackStore.getList(function(aTPoints) {
416         document.getElementById("debug").textContent = aTPoints.length + " points loaded.";
417         if (aTPoints.length) {
418           gTrack = aTPoints;
419         }
420       });
421     else
422       setTimeout(getStoredTrack, 100);
423     loopCnt++;
424     if (loopCnt > 20)
425       return;
426   };
427   getStoredTrack();
428   if (navigator.geolocation) {
429     //gGeoWatchID = geofake.watchPosition(
430     gGeoWatchID = navigator.geolocation.watchPosition(
431       function(position) {
432         // Coords spec: https://developer.mozilla.org/en/XPCOM_Interface_Reference/NsIDOMGeoPositionCoords
433         var tPoint = {time: position.timestamp,
434                       coords: position.coords,
435                       beginSegment: !gLastTrackPoint};
436         gTrack.push(tPoint);
437         gTrackStore.push(tPoint);
438         drawTrackPoint(position.coords.latitude, position.coords.longitude);
439         if (gCenterPosition) {
440           var posCoord = gps2xy(position.coords.latitude, position.coords.longitude);
441           if (Math.abs(gPos.x - posCoord.x) > gCanvas.width * gZoomFactor / 4 ||
442               Math.abs(gPos.y - posCoord.y) > gCanvas.height * gZoomFactor / 4) {
443             gPos.x = posCoord.x;
444             gPos.y = posCoord.y;
445             drawMap();
446           }
447         }
448       },
449       function(error) {
450         // Ignore erros for the moment, but this is good for debugging.
451         // See https://developer.mozilla.org/en/Using_geolocation#Handling_errors
452         document.getElementById("debug").textContent = error.message;
453       },
454       {enableHighAccuracy: true}
455     );
456   }
457 }
458
459 function endTracking() {
460   if (gGeoWatchID) {
461     navigator.geolocation.clearWatch(gGeoWatchID);
462   }
463 }
464
465 function clearTrack() {
466   gTrack = [];
467   gTrackStore.clear();
468   drawMap();
469 }