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