(function() {
"use strict";
//Zoomlevels on the server
var MAX_ZOOMLEVEL = 20;
//Size of a single tile
var BITS_PER_TILE = 8; // 256 -> 8 bits...
//This will be our resolution
var NR_OF_BITS = MAX_ZOOMLEVEL + BITS_PER_TILE; // < 2^31 ?
var NR_OF_PIXELS_ON_LAST_ZOOMLEVEL = 1 << NR_OF_BITS; // 2^28
//-------------
/** @class Coordinate
* @abstract
*/
var Coordinate = function() {};
Coordinate.prototype = {
precision: NR_OF_BITS,
x: undefined,
y: undefined,
offset: function(coordinate) {
//TODO: it's actually tricky with multiple precisions, homework... ;-)
//Hint: What does 3 << -1 give in JS?
var coord = new Coordinate();
coord.x = this.x + coordinate.x;
coord.y = this.y + coordinate.y;
return coord;
}
};
//helper function
var beget = function(o) {
var F = function() {};
F.prototype = o;
return new F();
};
/** @class GeoCoordinate
* @implements Coordinate
* A GeoCoordinate is a Coordinate in WGS-84 space, stored in max-resolution mercator.
*/
var GeoCoordinate = function(lat, lng) {
this.lat = lat;
this.lng = lng;
this.doTheMercatorMagic(lat, lng);
};
GeoCoordinate.prototype = beget(Coordinate.prototype);
GeoCoordinate.prototype.precision = NR_OF_BITS;
GeoCoordinate.prototype.doTheMercatorMagic = function(lat, lng) {
//Flatten the Earth! Yay!
var m = Math,
min = m.min,
max = m.max,
tan = m.tan,
PI = m.PI,
exp = m.exp,
log = m.log,
round = m.round;
this.x = round((lng / 360 + 0.5) * NR_OF_PIXELS_ON_LAST_ZOOMLEVEL);
this.y = round(min(1, max(0, 0.5 - (log(tan(PI / 4 + PI / 2 * lat / 180)) / PI) / 2)) * NR_OF_PIXELS_ON_LAST_ZOOMLEVEL);
};
Object.defineProperty(GeoCoordinate.prototype, "column", {
get: function() {
return this.x;
},
set: function(v) {
this.x = v;
},
});
Object.defineProperty(GeoCoordinate.prototype, "row", {
get: function() {
return this.y;
},
set: function(v) {
this.y = v;
},
});
/** @class TileCoordinate
* @implements Coordinate
* A TileCoordinate is a coordinate of a tile
*/
var TileCoordinate = function(zoomlevel, coordinate_or_column, row_if_column) {
this.zoomlevel = zoomlevel;
if (coordinate_or_column instanceof Coordinate) {
this.x = coordinate_or_column.x;
this.y = coordinate_or_column.y;
} else {
this.column = coordinate_or_column;
this.row = row_if_column;
}
};
TileCoordinate.prototype = beget(Coordinate.prototype);
TileCoordinate.prototype.zoomlevel = Coordinate.prototype.precision;
Object.defineProperty(TileCoordinate.prototype, "id", {
get: function() {
return [this.zoomlevel, this.column, this.row].join("/");
},
set: function(v) {
var data = v.split('/');
this.zoomlevel = data[0];
this.column = data[1];
this.row = data[2];
},
});
Object.defineProperty(TileCoordinate.prototype, "column", {
get: function() {
return this.x >> (this.precision - this.zoomlevel);
},
set: function(v) {
this.x = v << (this.precision - this.zoomlevel);
},
});
Object.defineProperty(TileCoordinate.prototype, "row", {
get: function() {
return this.y >> (this.precision - this.zoomlevel);
},
set: function(v) {
this.y = v << (this.precision - this.zoomlevel);
},
});
/** @class ScreenCoordinate
* @implements Coordinate
* A ScreenCoordinate is a coordinate on the screen of a given map.
* It's very similar to TileCoordinate, except it has an offset,
* and it's implemented separately only because of differences in variable names.
*/
var ScreenCoordinate = function(map, coordinate_or_column, row_if_column) {
this.map = map;
if (coordinate_or_column instanceof Coordinate) {
this.x = coordinate_or_column.x;
this.y = coordinate_or_column.y;
} else {
this.column = coordinate_or_column;
this.row = row_if_column;
}
};
ScreenCoordinate.prototype = beget(Coordinate.prototype);
ScreenCoordinate.prototype.zoomlevel = Coordinate.prototype.precision;
Object.defineProperty(ScreenCoordinate.prototype, "column", {
get: function() {
return (this.x - this.map.topleft.x) >> (this.precision - this.map.zoomlevel - BITS_PER_TILE);
},
set: function(v) {
this.x = v << (this.precision - this.map.zoomlevel - BITS_PER_TILE) + this.map.topleft.x;
},
});
Object.defineProperty(ScreenCoordinate.prototype, "row", {
get: function() {
return (this.y - this.map.topleft.y) >> (this.precision - this.map.zoomlevel - BITS_PER_TILE);
},
set: function(v) {
this.y = v << (this.precision - this.map.zoomlevel - BITS_PER_TILE) + this.map.topleft.y;
},
});
/**@class ScreenOffsetCoordinate
@extends TileCoordinate
This is a helper class to help calculate with screen offsets
*/
var ScreenOffsetCoordinate = function(map, offset_x, offset_y) {
this.map = map;
this.column = offset_x;
this.row = offset_y;
}
ScreenOffsetCoordinate.prototype = beget(TileCoordinate.prototype);
Object.defineProperty(ScreenOffsetCoordinate.prototype, "zoomlevel", {
get: function() {
return (this.map.zoomlevel + BITS_PER_TILE);
},
set: function() {
throw new Exception('cannot set zoomlevel of a map-dependent coordinate like ScreenOffsetCoordinate');
}
});
//TODO: testsuite - we do have one, but it would be boring to show one here.
/** @class Map
* A map - or, in this case, a mapview - is an actual widget on the screen
*/
var Map = function(canvas_el, options) {
this.canvas = canvas_el;
if (options) {
jQuery.extend(this, options); //shorter than for p in..
}
if (this.startposition) {
this.topleft = new GeoCoordinate(this.startposition.lat, this.startposition.lon);
}
this.ctx = canvas_el.getContext('2d');
this.render();
};
Map.prototype = {
canvas: undefined,
moving: false,
get width() {
return this.canvas.width;
},
get height() {
return this.canvas.height;
},
ctx: undefined,
startposition: {
lat: 47.49658,
lon: 19.057811
},
rendered: false,
_zoomlevel: 15,
get zoomlevel() {
return this._zoomlevel;
},
set zoomlevel(v) {
this._zoomlevel = v;
if (this.rendered) {
this.render();
}
},
_topleft: new GeoCoordinate(47.49658, 19.057811),
get topleft() {
return this._topleft;
},
set topleft(v) {
this._topleft = v;
if (this.rendered) {
this.render();
}
},
render: function() {
window.stop();
var tile_x, tile_y, position;
var topleft_tile_coordinate = new TileCoordinate(this.zoomlevel, this.topleft);
var bottomright_tile_coordinate = new TileCoordinate(this.zoomlevel, this.topleft.offset(new ScreenOffsetCoordinate(this, this.width, this.height)));
var x, y;
for (x = topleft_tile_coordinate.column; x <= bottomright_tile_coordinate.column + 1; ++x) {
for (y = topleft_tile_coordinate.row; y <= bottomright_tile_coordinate.row + 1; ++y) {
this.load_tile_img(new TileCoordinate(this.zoomlevel, x, y), function cb(img, tilecoordinate) {
var coordinate = new ScreenCoordinate(this, tilecoordinate);
this.ctx.drawImage(img, coordinate.column, coordinate.row);
});
}
}
this.rendered = true;
},
tileCache: {},
load_tile_img: function(tilecoordinate, callback) {
var that = this;
var url = this.get_tile_url(tilecoordinate);
var img = new Image();
if (that.tileCache[tilecoordinate.id]){
callback.call(that, that.tileCache[tilecoordinate.id], tilecoordinate);
} else if (!this.is_moving()){
img.src = url;
img.onload = function() {
that.tileCache[tilecoordinate.id] = img;
callback.call(that, img, tilecoordinate);
};
}
},
get_tile_url: function(tilecoordinate) {
var id = tilecoordinate.id;
var url = "http://" + ['a', 'b', 'c'][Math.floor(Math.random() * 3)] + ".maptile.maps.svc.ovi.com/maptiler/v2/maptile/newest/normal.day/" + id + "/256/png8";
return url;
},
start_move: function(){
this.moving = true;
},
stop_move: function(){
this.moving = false;
this.render();
},
is_moving: function(){
return this.moving;
},
pan: function(offset_x, offset_y) {
this.topleft = this.topleft.offset(new ScreenOffsetCoordinate(this, offset_x, offset_y));
},
zoom_in_at: function(offset_x, offset_y) {
this._topleft = this._topleft.offset(new ScreenOffsetCoordinate(this, offset_x/2, offset_y/2)); // not to evoke render() twice
this.zoomlevel += 1;
},
zoom_out_at: function(offset_x, offset_y) {
this._topleft = this._topleft.offset(new ScreenOffsetCoordinate(this, -1*offset_x, -1*offset_y)); // not to evoke render() twice
this.zoomlevel -= 1;
}
};
var map = new Map(document.getElementById('mymap'), {
zoomlevel: 16
});
var on = function(id, ev, cb) {
document.getElementById(id).addEventListener(ev, cb, true);
};
var click = function(id, cb) {
on(id, 'click', cb);
}
click('zoomin', function() {
map.zoomlevel += 1;
});
click('zoomout', function() {
map.zoomlevel -= 1;
});
click('panwest', function() {
map.pan(-15, 0);
});
click('paneast', function() {
map.pan(15, 0);
});
click('pannorth', function() {
map.pan(0, -15);
});
click('pansouth', function() {
map.pan(0, 15);
});
var mouse_last_x, mouse_last_y;
on('mymap', 'mousedown', function(ev) {
map.start_move();
mouse_last_x = ev.pageX;
mouse_last_y = ev.pageY;
});
on('mymap', 'mouseup', function() {
map.stop_move();
});
on('mymap', 'mouseout', function() {
map.stop_move();
});
on('mymap', 'mousemove', function(ev) {
if (map.is_moving()) {
var offset_x = mouse_last_x - ev.pageX;
var offset_y = mouse_last_y - ev.pageY;
map.pan(offset_x, offset_y);
mouse_last_x = ev.pageX;
mouse_last_y = ev.pageY;
}
});
on('mymap', 'dblclick', function(ev) {
map.zoom_in_at(ev.offsetX, ev.offsetY);
});
on('mymap', 'contextmenu', function(ev) {
map.zoom_out_at(ev.offsetX, ev.offsetY);
return false;
});
var mouse_wheel_enabled = true;
on('mymap', 'mousewheel', function(ev) {
if (mouse_wheel_enabled) {
var wheel_property;
if (ev.wheelDelta) wheel_property = ev.wheelDelta;
if (ev.detail) wheel_property = (-1) * ev.detail;
if (wheel_property > 0) {
map.zoom_in_at(ev.offsetX, ev.offsetY);
} else if (wheel_property < 0) {
map.zoom_out_at(ev.offsetX, ev.offsetY);
}
mouse_wheel_enabled = false;
window.setTimeout(function() {
mouse_wheel_enabled = true;
}, 100);
}
});
})();
<button id="zoomin">+</button>
<button id="zoomout">-</button>
<button id="panwest"><</button>
<button id="pannorth">^</button>
<button id="pansouth">v</button>
<button id="paneast">></button>
<br>
<canvas id="mymap" width=500 height=500></canvas>