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