/*! * Modest Maps JS v1.0.0-beta * http://modestmaps.com/ * * Copyright (c) 2011 Stamen Design, All Rights Reserved. * * Open source under the BSD License. * http://creativecommons.org/licenses/BSD/ * * Versioned using Semantic Versioning (v.major.minor.patch) * See CHANGELOG and http://semver.org/ for more details. * */ var previousMM = MM; // namespacing for backwards-compatibility if (!com) { var com = {}; if (!com.modestmaps) com.modestmaps = {}; } var MM = com.modestmaps = { noConflict: function() { MM = previousMM; return this; } }; (function(MM) { // Make inheritance bearable: clone one level of properties MM.extend = function(child, parent) { for (var property in parent.prototype) { if (typeof child.prototype[property] == "undefined") { child.prototype[property] = parent.prototype[property]; } } return child; }; MM.getFrame = function () { // native animation frames // http://webstuff.nfshost.com/anim-timing/Overview.html // http://dev.chromium.org/developers/design-documents/requestanimationframe-implementation // http://paulirish.com/2011/requestanimationframe-for-smart-animating/ // can't apply these directly to MM because Chrome needs window // to own webkitRequestAnimationFrame (for example) // perhaps we should namespace an alias onto window instead? // e.g. window.mmRequestAnimationFrame? return function(callback) { (window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame || function (callback) { window.setTimeout(function () { callback(+new Date()); }, 10); })(callback); }; }(); // Inspired by LeafletJS MM.transformProperty = (function(props) { if (!this.document) return; // node.js safety var style = document.documentElement.style; for (var i = 0; i < props.length; i++) { if (props[i] in style) { return props[i]; } } return false; })(['transformProperty', 'WebkitTransform', 'OTransform', 'MozTransform', 'msTransform']); MM.matrixString = function(point) { // Make the result of point.scale * point.width a whole number. if (point.scale * point.width % 1) { point.scale += (1 - point.scale * point.width % 1) / point.width; } if (MM._browser.webkit3d) { return 'matrix3d(' + [(point.scale || '1'), '0,0,0,0', (point.scale || '1'), '0,0', '0,0,1,0', (point.x + (((point.width * point.scale) - point.width) / 2)).toFixed(4), (point.y + (((point.height * point.scale) - point.height) / 2)).toFixed(4), 0,1].join(',') + ')'; } else { var unit = (MM.transformProperty == 'MozTransform') ? 'px' : ''; return 'matrix(' + [(point.scale || '1'), 0, 0, (point.scale || '1'), (point.x + (((point.width * point.scale) - point.width) / 2)) + unit, (point.y + (((point.height * point.scale) - point.height) / 2)) + unit ].join(',') + ')'; } }; MM._browser = (function(window) { return { webkit: ('WebKitCSSMatrix' in window), webkit3d: ('WebKitCSSMatrix' in window) && ('m11' in new WebKitCSSMatrix()) }; })(this); // use this for node.js global MM.moveElement = function(el, point) { if (MM.transformProperty) { // Optimize for identity transforms, where you don't actually // need to change this element's string. Browsers can optimize for // the .style.left case but not for this CSS case. if (!point.scale) point.scale = 1; if (!point.width) point.width = 0; if (!point.height) point.height = 0; var ms = MM.matrixString(point); if (el[MM.transformProperty] !== ms) { el.style[MM.transformProperty] = el[MM.transformProperty] = ms; } } else { el.style.left = point.x + 'px'; el.style.top = point.y + 'px'; // Don't set width unless asked to: this is performance-intensive // and not always necessary if (point.width && point.height && point.scale) { el.style.width = Math.ceil(point.width * point.scale) + 'px'; el.style.height = Math.ceil(point.height * point.scale) + 'px'; } } }; // Events // Cancel an event: prevent it from bubbling MM.cancelEvent = function(e) { // there's more than one way to skin this cat e.cancelBubble = true; e.cancel = true; e.returnValue = false; if (e.stopPropagation) { e.stopPropagation(); } if (e.preventDefault) { e.preventDefault(); } return false; }; // From underscore.js MM.bind = function(func, obj) { var slice = Array.prototype.slice; var nativeBind = Function.prototype.bind; if (func.bind === nativeBind && nativeBind) { return nativeBind.apply(func, slice.call(arguments, 1)); } var args = slice.call(arguments, 2); return function() { return func.apply(obj, args.concat(slice.call(arguments))); }; }; MM.coerceLayer = function(layerish) { if (typeof layerish == 'string') { // Probably a template string return new MM.Layer(new MM.TemplatedMapProvider(layerish)); } else if ('draw' in layerish && typeof layerish.draw == 'function') { // good enough, though we should probably enforce .parent and .destroy() too return layerish; } else { // probably a MapProvider return new MM.Layer(layerish); } }; // see http://ejohn.org/apps/jselect/event.html for the originals MM.addEvent = function(obj, type, fn) { if (obj.addEventListener) { obj.addEventListener(type, fn, false); if (type == 'mousewheel') { obj.addEventListener('DOMMouseScroll', fn, false); } } else if (obj.attachEvent) { obj['e'+type+fn] = fn; obj[type+fn] = function(){ obj['e'+type+fn](window.event); }; obj.attachEvent('on'+type, obj[type+fn]); } }; MM.removeEvent = function( obj, type, fn ) { if (obj.removeEventListener) { obj.removeEventListener(type, fn, false); if (type == 'mousewheel') { obj.removeEventListener('DOMMouseScroll', fn, false); } } else if (obj.detachEvent) { obj.detachEvent('on'+type, obj[type+fn]); obj[type+fn] = null; } }; // Cross-browser function to get current element style property MM.getStyle = function(el,styleProp) { if (el.currentStyle) return el.currentStyle[styleProp]; else if (window.getComputedStyle) return document.defaultView.getComputedStyle(el,null).getPropertyValue(styleProp); }; // Point MM.Point = function(x, y) { this.x = parseFloat(x); this.y = parseFloat(y); }; MM.Point.prototype = { x: 0, y: 0, toString: function() { return "(" + this.x.toFixed(3) + ", " + this.y.toFixed(3) + ")"; }, copy: function() { return new MM.Point(this.x, this.y); } }; // Get the euclidean distance between two points MM.Point.distance = function(p1, p2) { return Math.sqrt( Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); }; // Get a point between two other points, biased by `t`. MM.Point.interpolate = function(p1, p2, t) { return new MM.Point( p1.x + (p2.x - p1.x) * t, p1.y + (p2.y - p1.y) * t); }; // Coordinate // ---------- // An object representing a tile position, at as specified zoom level. // This is not necessarily a precise tile - `row`, `column`, and // `zoom` can be floating-point numbers, and the `container()` function // can be used to find the actual tile that contains the point. MM.Coordinate = function(row, column, zoom) { this.row = row; this.column = column; this.zoom = zoom; }; MM.Coordinate.prototype = { row: 0, column: 0, zoom: 0, toString: function() { return "(" + this.row.toFixed(3) + ", " + this.column.toFixed(3) + " @" + this.zoom.toFixed(3) + ")"; }, // Quickly generate a string representation of this coordinate to // index it in hashes. toKey: function() { // We've tried to use efficient hash functions here before but we took // them out. Contributions welcome but watch out for collisions when the // row or column are negative and check thoroughly (exhaustively) before // committing. return this.zoom + ',' + this.row + ',' + this.column; }, // Clone this object. copy: function() { return new MM.Coordinate(this.row, this.column, this.zoom); }, // Get the actual, rounded-number tile that contains this point. container: function() { // using floor here (not parseInt, ~~) because we want -0.56 --> -1 return new MM.Coordinate(Math.floor(this.row), Math.floor(this.column), Math.floor(this.zoom)); }, // Recalculate this Coordinate at a different zoom level and return the // new object. zoomTo: function(destination) { var power = Math.pow(2, destination - this.zoom); return new MM.Coordinate(this.row * power, this.column * power, destination); }, // Recalculate this Coordinate at a different relative zoom level and return the // new object. zoomBy: function(distance) { var power = Math.pow(2, distance); return new MM.Coordinate(this.row * power, this.column * power, this.zoom + distance); }, // Move this coordinate up by `dist` coordinates up: function(dist) { if (dist === undefined) dist = 1; return new MM.Coordinate(this.row - dist, this.column, this.zoom); }, // Move this coordinate right by `dist` coordinates right: function(dist) { if (dist === undefined) dist = 1; return new MM.Coordinate(this.row, this.column + dist, this.zoom); }, // Move this coordinate down by `dist` coordinates down: function(dist) { if (dist === undefined) dist = 1; return new MM.Coordinate(this.row + dist, this.column, this.zoom); }, // Move this coordinate left by `dist` coordinates left: function(dist) { if (dist === undefined) dist = 1; return new MM.Coordinate(this.row, this.column - dist, this.zoom); } }; // Location // -------- MM.Location = function(lat, lon) { this.lat = parseFloat(lat); this.lon = parseFloat(lon); }; MM.Location.prototype = { lat: 0, lon: 0, toString: function() { return "(" + this.lat.toFixed(3) + ", " + this.lon.toFixed(3) + ")"; }, copy: function() { return new MM.Location(this.lat, this.lon); } }; // returns approximate distance between start and end locations // // default unit is meters // // you can specify different units by optionally providing the // earth's radius in the units you desire // // Default is 6,378,000 metres, suggested values are: // // * 3963.1 statute miles // * 3443.9 nautical miles // * 6378 km // // see [Formula and code for calculating distance based on two lat/lon locations](http://jan.ucc.nau.edu/~cvm/latlon_formula.html) MM.Location.distance = function(l1, l2, r) { if (!r) { // default to meters r = 6378000; } var deg2rad = Math.PI / 180.0, a1 = l1.lat * deg2rad, b1 = l1.lon * deg2rad, a2 = l2.lat * deg2rad, b2 = l2.lon * deg2rad, c = Math.cos(a1) * Math.cos(b1) * Math.cos(a2) * Math.cos(b2), d = Math.cos(a1) * Math.sin(b1) * Math.cos(a2) * Math.sin(b2), e = Math.sin(a1) * Math.sin(a2); return Math.acos(c + d + e) * r; }; // Interpolates along a great circle, f between 0 and 1 // // * FIXME: could be heavily optimized (lots of trig calls to cache) // * FIXME: could be inmproved for calculating a full path MM.Location.interpolate = function(l1, l2, f) { if (l1.lat === l2.lat && l1.lon === l2.lon) { return new MM.Location(l1.lat, l1.lon); } var deg2rad = Math.PI / 180.0, lat1 = l1.lat * deg2rad, lon1 = l1.lon * deg2rad, lat2 = l2.lat * deg2rad, lon2 = l2.lon * deg2rad; var d = 2 * Math.asin( Math.sqrt( Math.pow(Math.sin((lat1 - lat2) / 2), 2) + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin((lon1 - lon2) / 2), 2))); var bearing = Math.atan2( Math.sin(lon1 - lon2) * Math.cos(lat2), Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon1 - lon2) ) / -(Math.PI / 180); bearing = bearing < 0 ? 360 + bearing : bearing; var A = Math.sin((1-f)*d)/Math.sin(d); var B = Math.sin(f*d)/Math.sin(d); var x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2) * Math.cos(lon2); var y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2) * Math.sin(lon2); var z = A * Math.sin(lat1) + B * Math.sin(lat2); var latN = Math.atan2(z, Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))); var lonN = Math.atan2(y,x); return new MM.Location(latN / deg2rad, lonN / deg2rad); }; // Extent // ---------- // An object representing a map's rectangular extent, defined by its north, // south, east and west bounds. MM.Extent = function(north, west, south, east) { if (north instanceof MM.Location && west instanceof MM.Location) { var northwest = north, southeast = west; north = northwest.lat; west = northwest.lon; south = southeast.lat; east = southeast.lon; } if (isNaN(south)) south = north; if (isNaN(east)) east = west; this.north = Math.max(north, south); this.south = Math.min(north, south); this.east = Math.max(east, west); this.west = Math.min(east, west); }; MM.Extent.prototype = { // boundary attributes north: 0, south: 0, east: 0, west: 0, copy: function() { return new MM.Extent(this.north, this.west, this.south, this.east); }, toString: function(precision) { if (isNaN(precision)) precision = 3; return [ this.north.toFixed(precision), this.west.toFixed(precision), this.south.toFixed(precision), this.east.toFixed(precision) ].join(", "); }, // getters for the corner locations northWest: function() { return new MM.Location(this.north, this.west); }, southEast: function() { return new MM.Location(this.south, this.east); }, northEast: function() { return new MM.Location(this.north, this.east); }, southWest: function() { return new MM.Location(this.south, this.west); }, // getter for the center location center: function() { return new MM.Location( this.south + (this.north - this.south) / 2, this.east + (this.west - this.east) / 2 ); }, // extend the bounds to include a location's latitude and longitude encloseLocation: function(loc) { if (loc.lat > this.north) this.north = loc.lat; if (loc.lat < this.south) this.south = loc.lat; if (loc.lon > this.east) this.east = loc.lon; if (loc.lon < this.west) this.west = loc.lon; }, // extend the bounds to include multiple locations encloseLocations: function(locations) { var len = locations.length; for (var i = 0; i < len; i++) { this.encloseLocation(locations[i]); } }, // reset bounds from a list of locations setFromLocations: function(locations) { var len = locations.length, first = locations[0]; this.north = this.south = first.lat; this.east = this.west = first.lon; for (var i = 1; i < len; i++) { this.encloseLocation(locations[i]); } }, // extend the bounds to include another extent encloseExtent: function(extent) { if (extent.north > this.north) this.north = extent.north; if (extent.south < this.south) this.south = extent.south; if (extent.east > this.east) this.east = extent.east; if (extent.west < this.west) this.west = extent.west; }, // determine if a location is within this extent containsLocation: function(loc) { return loc.lat >= this.south && loc.lat <= this.north && loc.lon >= this.west && loc.lon <= this.east; }, // turn an extent into an array of locations containing its northwest // and southeast corners (used in MM.Map.setExtent()) toArray: function() { return [this.northWest(), this.southEast()]; } }; MM.Extent.fromString = function(str) { var parts = str.split(/\s*,\s*/); if (parts.length != 4) { throw "Invalid extent string (expecting 4 comma-separated numbers)"; } return new MM.Extent( parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3]) ); }; MM.Extent.fromArray = function(locations) { var extent = new MM.Extent(); extent.setFromLocations(locations); return extent; }; // Transformation // -------------- MM.Transformation = function(ax, bx, cx, ay, by, cy) { this.ax = ax; this.bx = bx; this.cx = cx; this.ay = ay; this.by = by; this.cy = cy; }; MM.Transformation.prototype = { ax: 0, bx: 0, cx: 0, ay: 0, by: 0, cy: 0, transform: function(point) { return new MM.Point(this.ax * point.x + this.bx * point.y + this.cx, this.ay * point.x + this.by * point.y + this.cy); }, untransform: function(point) { return new MM.Point((point.x * this.by - point.y * this.bx - this.cx * this.by + this.cy * this.bx) / (this.ax * this.by - this.ay * this.bx), (point.x * this.ay - point.y * this.ax - this.cx * this.ay + this.cy * this.ax) / (this.bx * this.ay - this.by * this.ax)); } }; // Generates a transform based on three pairs of points, // a1 -> a2, b1 -> b2, c1 -> c2. MM.deriveTransformation = function(a1x, a1y, a2x, a2y, b1x, b1y, b2x, b2y, c1x, c1y, c2x, c2y) { var x = MM.linearSolution(a1x, a1y, a2x, b1x, b1y, b2x, c1x, c1y, c2x); var y = MM.linearSolution(a1x, a1y, a2y, b1x, b1y, b2y, c1x, c1y, c2y); return new MM.Transformation(x[0], x[1], x[2], y[0], y[1], y[2]); }; // Solves a system of linear equations. // // t1 = (a * r1) + (b + s1) + c // t2 = (a * r2) + (b + s2) + c // t3 = (a * r3) + (b + s3) + c // // r1 - t3 are the known values. // a, b, c are the unknowns to be solved. // returns the a, b, c coefficients. MM.linearSolution = function(r1, s1, t1, r2, s2, t2, r3, s3, t3) { // make them all floats r1 = parseFloat(r1); s1 = parseFloat(s1); t1 = parseFloat(t1); r2 = parseFloat(r2); s2 = parseFloat(s2); t2 = parseFloat(t2); r3 = parseFloat(r3); s3 = parseFloat(s3); t3 = parseFloat(t3); var a = (((t2 - t3) * (s1 - s2)) - ((t1 - t2) * (s2 - s3))) / (((r2 - r3) * (s1 - s2)) - ((r1 - r2) * (s2 - s3))); var b = (((t2 - t3) * (r1 - r2)) - ((t1 - t2) * (r2 - r3))) / (((s2 - s3) * (r1 - r2)) - ((s1 - s2) * (r2 - r3))); var c = t1 - (r1 * a) - (s1 * b); return [ a, b, c ]; }; // Projection // ---------- // An abstract class / interface for projections MM.Projection = function(zoom, transformation) { if (!transformation) { transformation = new MM.Transformation(1, 0, 0, 0, 1, 0); } this.zoom = zoom; this.transformation = transformation; }; MM.Projection.prototype = { zoom: 0, transformation: null, rawProject: function(point) { throw "Abstract method not implemented by subclass."; }, rawUnproject: function(point) { throw "Abstract method not implemented by subclass."; }, project: function(point) { point = this.rawProject(point); if(this.transformation) { point = this.transformation.transform(point); } return point; }, unproject: function(point) { if(this.transformation) { point = this.transformation.untransform(point); } point = this.rawUnproject(point); return point; }, locationCoordinate: function(location) { var point = new MM.Point(Math.PI * location.lon / 180.0, Math.PI * location.lat / 180.0); point = this.project(point); return new MM.Coordinate(point.y, point.x, this.zoom); }, coordinateLocation: function(coordinate) { coordinate = coordinate.zoomTo(this.zoom); var point = new MM.Point(coordinate.column, coordinate.row); point = this.unproject(point); return new MM.Location(180.0 * point.y / Math.PI, 180.0 * point.x / Math.PI); } }; // A projection for equilateral maps, based on longitude and latitude MM.LinearProjection = function(zoom, transformation) { MM.Projection.call(this, zoom, transformation); }; // The Linear projection doesn't reproject points MM.LinearProjection.prototype = { rawProject: function(point) { return new MM.Point(point.x, point.y); }, rawUnproject: function(point) { return new MM.Point(point.x, point.y); } }; MM.extend(MM.LinearProjection, MM.Projection); MM.MercatorProjection = function(zoom, transformation) { // super! MM.Projection.call(this, zoom, transformation); }; // Project lon/lat points into meters required for Mercator MM.MercatorProjection.prototype = { rawProject: function(point) { return new MM.Point(point.x, Math.log(Math.tan(0.25 * Math.PI + 0.5 * point.y))); }, rawUnproject: function(point) { return new MM.Point(point.x, 2 * Math.atan(Math.pow(Math.E, point.y)) - 0.5 * Math.PI); } }; MM.extend(MM.MercatorProjection, MM.Projection); // Providers // --------- // Providers provide tile URLs and possibly elements for layers. // // MapProvider -> // TemplatedMapProvider // MM.MapProvider = function(getTile) { if (getTile) { this.getTile = getTile; } }; MM.MapProvider.prototype = { // these are limits for available *tiles* // panning limits will be different (since you can wrap around columns) // but if you put Infinity in here it will screw up sourceCoordinate tileLimits: [ new MM.Coordinate(0,0,0), // top left outer new MM.Coordinate(1,1,0).zoomTo(18) // bottom right inner ], getTileUrl: function(coordinate) { throw "Abstract method not implemented by subclass."; }, getTile: function(coordinate) { throw "Abstract method not implemented by subclass."; }, // releaseTile is not required releaseTile: function(element) { }, // use this to tell MapProvider that tiles only exist between certain zoom levels. // should be set separately on Map to restrict interactive zoom/pan ranges setZoomRange: function(minZoom, maxZoom) { this.tileLimits[0] = this.tileLimits[0].zoomTo(minZoom); this.tileLimits[1] = this.tileLimits[1].zoomTo(maxZoom); }, // return null if coord is above/below row extents // wrap column around the world if it's outside column extents // ... you should override this function if you change the tile limits // ... see enforce-limits in examples for details sourceCoordinate: function(coord) { var TL = this.tileLimits[0].zoomTo(coord.zoom), BR = this.tileLimits[1].zoomTo(coord.zoom), columnSize = Math.pow(2, coord.zoom), wrappedColumn; if (coord.column < 0) { wrappedColumn = (coord.column + columnSize) % columnSize; } else { wrappedColumn = coord.column % columnSize; } if (coord.row < TL.row || coord.row >= BR.row) { return null; } else if (wrappedColumn < TL.column || wrappedColumn >= BR.column) { return null; } else { return new MM.Coordinate(coord.row, wrappedColumn, coord.zoom); } } }; /** * FIXME: need a better explanation here! This is a pretty crucial part of * understanding how to use ModestMaps. * * TemplatedMapProvider is a tile provider that generates tile URLs from a * template string by replacing the following bits for each tile * coordinate: * * {Z}: the tile's zoom level (from 1 to ~20) * {X}: the tile's X, or column (from 0 to a very large number at higher * zooms) * {Y}: the tile's Y, or row (from 0 to a very large number at higher * zooms) * * E.g.: * * var osm = new MM.TemplatedMapProvider("http://tile.openstreetmap.org/{Z}/{X}/{Y}.png"); * * Or: * * var placeholder = new MM.TemplatedMapProvider("http://placehold.it/256/f0f/fff.png&text={Z}/{X}/{Y}"); * */ MM.TemplatedMapProvider = function(template, subdomains) { var isQuadKey = template.match(/{(Q|quadkey)}/); // replace Microsoft style substitution strings if (isQuadKey) template = template .replace('{subdomains}', '{S}') .replace('{zoom}', '{Z}') .replace('{quadkey}', '{Q}'); var hasSubdomains = (subdomains && subdomains.length && template.indexOf("{S}") >= 0); var getTileUrl = function(coordinate) { var coord = this.sourceCoordinate(coordinate); if (!coord) { return null; } var base = template; if (hasSubdomains) { var index = parseInt(coord.zoom + coord.row + coord.column, 10) % subdomains.length; base = base.replace('{S}', subdomains[index]); } if (isQuadKey) { return base .replace('{Z}', coord.zoom.toFixed(0)) .replace('{Q}', this.quadKey(coord.row, coord.column, coord.zoom)); } else { return base .replace('{Z}', coord.zoom.toFixed(0)) .replace('{X}', coord.column.toFixed(0)) .replace('{Y}', coord.row.toFixed(0)); } }; MM.MapProvider.call(this, getTileUrl); }; MM.TemplatedMapProvider.prototype = { // quadKey generator quadKey: function(row, column, zoom) { var key = ''; for (var i = 1; i <= zoom; i++) { key += (((row >> zoom - i) & 1) << 1) | ((column >> zoom - i) & 1); } return key || '0'; }, getTile: function(coord) { return this.getTileUrl(coord); } }; MM.extend(MM.TemplatedMapProvider, MM.MapProvider); MM.TemplatedLayer = function(template, subdomains) { return new MM.Layer(new MM.TemplatedMapProvider(template, subdomains)); }; // Event Handlers // -------------- // A utility function for finding the offset of the // mouse from the top-left of the page MM.getMousePoint = function(e, map) { // start with just the mouse (x, y) var point = new MM.Point(e.clientX, e.clientY); // correct for scrolled document point.x += document.body.scrollLeft + document.documentElement.scrollLeft; point.y += document.body.scrollTop + document.documentElement.scrollTop; // correct for nested offsets in DOM for (var node = map.parent; node; node = node.offsetParent) { point.x -= node.offsetLeft; point.y -= node.offsetTop; } return point; }; // A handler that allows mouse-wheel zooming - zooming in // when page would scroll up, and out when the page would scroll down. MM.MouseWheelHandler = function(map, precise) { // only init() if we get a map if (map) { this.init(map, precise); // allow (null, true) as constructor args } else if (arguments.length > 1) { this.precise = precise ? true : false; } }; MM.MouseWheelHandler.prototype = { precise: false, init: function(map) { this.map = map; this._mouseWheel = MM.bind(this.mouseWheel, this); this._zoomDiv = document.body.appendChild(document.createElement('div')); this._zoomDiv.style.cssText = 'visibility:hidden;top:0;height:0;width:0;overflow-y:scroll'; var innerDiv = this._zoomDiv.appendChild(document.createElement('div')); innerDiv.style.height = '2000px'; MM.addEvent(map.parent, 'mousewheel', this._mouseWheel); }, remove: function() { MM.removeEvent(this.map.parent, 'mousewheel', this._mouseWheel); this._zoomDiv.parentNode.removeChild(this._zoomDiv); }, mouseWheel: function(e) { var delta = 0; this.prevTime = this.prevTime || new Date().getTime(); try { this._zoomDiv.scrollTop = 1000; this._zoomDiv.dispatchEvent(e); delta = 1000 - this._zoomDiv.scrollTop; } catch (error) { delta = e.wheelDelta || (-e.detail * 5); } // limit mousewheeling to once every 200ms var timeSince = new Date().getTime() - this.prevTime; if (Math.abs(delta) > 0 && (timeSince > 200) && !this.precise) { var point = MM.getMousePoint(e, this.map); this.map.zoomByAbout(delta > 0 ? 1 : -1, point); this.prevTime = new Date().getTime(); } else if (this.precise) { var point = MM.getMousePoint(e, this.map); this.map.zoomByAbout(delta * 0.001, point); } // Cancel the event so that the page doesn't scroll return MM.cancelEvent(e); } }; // Handle double clicks, that zoom the map in one zoom level. MM.DoubleClickHandler = function(map) { if (map !== undefined) { this.init(map); } }; MM.DoubleClickHandler.prototype = { init: function(map) { this.map = map; this._doubleClick = MM.bind(this.doubleClick, this); MM.addEvent(map.parent, 'dblclick', this._doubleClick); }, remove: function() { MM.removeEvent(this.map.parent, 'dblclick', this._doubleClick); }, doubleClick: function(e) { // Ensure that this handler is attached once. // Get the point on the map that was double-clicked var point = MM.getMousePoint(e, this.map); // use shift-double-click to zoom out this.map.zoomByAbout(e.shiftKey ? -1 : 1, point); return MM.cancelEvent(e); } }; // Handle the use of mouse dragging to pan the map. MM.DragHandler = function(map) { if (map !== undefined) { this.init(map); } }; MM.DragHandler.prototype = { init: function(map) { this.map = map; this._mouseDown = MM.bind(this.mouseDown, this); MM.addEvent(map.parent, 'mousedown', this._mouseDown); }, remove: function() { MM.removeEvent(this.map.parent, 'mousedown', this._mouseDown); }, mouseDown: function(e) { MM.addEvent(document, 'mouseup', this._mouseUp = MM.bind(this.mouseUp, this)); MM.addEvent(document, 'mousemove', this._mouseMove = MM.bind(this.mouseMove, this)); this.prevMouse = new MM.Point(e.clientX, e.clientY); this.map.parent.style.cursor = 'move'; return MM.cancelEvent(e); }, mouseMove: function(e) { if (this.prevMouse) { this.map.panBy( e.clientX - this.prevMouse.x, e.clientY - this.prevMouse.y); this.prevMouse.x = e.clientX; this.prevMouse.y = e.clientY; this.prevMouse.t = +new Date(); } return MM.cancelEvent(e); }, mouseUp: function(e) { MM.removeEvent(document, 'mouseup', this._mouseUp); MM.removeEvent(document, 'mousemove', this._mouseMove); this.prevMouse = null; this.map.parent.style.cursor = ''; return MM.cancelEvent(e); } }; // A shortcut for adding drag, double click, // and mouse wheel events to the map. This is the default // handler attached to a map if the handlers argument isn't given. MM.MouseHandler = function(map) { if (map !== undefined) { this.init(map); } }; MM.MouseHandler.prototype = { init: function(map) { this.map = map; this.handlers = [ new MM.DragHandler(map), new MM.DoubleClickHandler(map), new MM.MouseWheelHandler(map) ]; }, remove: function() { for (var i = 0; i < this.handlers.length; i++) { this.handlers[i].remove(); } } }; var HAS_HASHCHANGE = (function() { var doc_mode = window.documentMode; return ('onhashchange' in window) && (doc_mode === undefined || doc_mode > 7); })(); MM.Hash = function(map) { this.onMapMove = MM.bind(this.onMapMove, this); this.onHashChange = MM.bind(this.onHashChange, this); if (map) { this.init(map); } }; MM.Hash.prototype = { map: null, lastHash: null, parseHash: function(hash) { var args = hash.split("/"); if (args.length == 3) { var zoom = parseInt(args[0], 10), lat = parseFloat(args[1]), lon = parseFloat(args[2]); if (isNaN(zoom) || isNaN(lat) || isNaN(lon)) { return false; } else { return { center: new MM.Location(lat, lon), zoom: zoom }; } } else { return false; } }, formatHash: function(map) { var center = map.getCenter(), zoom = map.getZoom(), precision = Math.max(0, Math.ceil(Math.log(zoom) / Math.LN2)); return "#" + [zoom, center.lat.toFixed(precision), center.lon.toFixed(precision) ].join("/"); }, init: function(map) { this.map = map; this.map.addCallback("drawn", this.onMapMove); // reset the hash this.lastHash = null; this.onHashChange(); if (!this.isListening) { this.startListening(); } }, remove: function() { this.map = null; if (this.isListening) { this.stopListening(); } }, onMapMove: function(map) { // bail if we're moving the map (updating from a hash), // or if the map has no zoom set if (this.movingMap || this.map.zoom === 0) { return false; } var hash = this.formatHash(map); if (this.lastHash != hash) { location.replace(hash); this.lastHash = hash; } }, movingMap: false, update: function() { var hash = location.hash; if (hash === this.lastHash) { // console.info("(no change)"); return; } var sansHash = hash.substr(1), parsed = this.parseHash(sansHash); if (parsed) { // console.log("parsed:", parsed.zoom, parsed.center.toString()); this.movingMap = true; this.map.setCenterZoom(parsed.center, parsed.zoom); this.movingMap = false; } else { // console.warn("parse error; resetting:", this.map.getCenter(), this.map.getZoom()); this.onMapMove(this.map); } }, // defer hash change updates every 100ms changeDefer: 100, changeTimeout: null, onHashChange: function() { // throttle calls to update() so that they only happen every // `changeDefer` ms if (!this.changeTimeout) { var that = this; this.changeTimeout = setTimeout(function() { that.update(); that.changeTimeout = null; }, this.changeDefer); } }, isListening: false, hashChangeInterval: null, startListening: function() { if (HAS_HASHCHANGE) { window.addEventListener("hashchange", this.onHashChange, false); } else { clearInterval(this.hashChangeInterval); this.hashChangeInterval = setInterval(this.onHashChange, 50); } this.isListening = true; }, stopListening: function() { if (HAS_HASHCHANGE) { window.removeEventListener("hashchange", this.onHashChange); } else { clearInterval(this.hashChangeInterval); } this.isListening = false; } }; MM.TouchHandler = function(map, options) { if (map) { this.init(map, options); } }; MM.TouchHandler.prototype = { maxTapTime: 250, maxTapDistance: 30, maxDoubleTapDelay: 350, locations: {}, taps: [], wasPinching: false, lastPinchCenter: null, init: function(map, options) { this.map = map; options = options || {}; // Fail early if this isn't a touch device. if (!this.isTouchable()) return false; this._touchStartMachine = MM.bind(this.touchStartMachine, this); this._touchMoveMachine = MM.bind(this.touchMoveMachine, this); this._touchEndMachine = MM.bind(this.touchEndMachine, this); MM.addEvent(map.parent, 'touchstart', this._touchStartMachine); MM.addEvent(map.parent, 'touchmove', this._touchMoveMachine); MM.addEvent(map.parent, 'touchend', this._touchEndMachine); this.options = {}; this.options.snapToZoom = options.snapToZoom || true; }, isTouchable: function() { var el = document.createElement('div'); el.setAttribute('ongesturestart', 'return;'); return (typeof el.ongesturestart === 'function'); }, remove: function() { // Fail early if this isn't a touch device. if (!this.isTouchable()) return false; MM.removeEvent(this.map.parent, 'touchstart', this._touchStartMachine); MM.removeEvent(this.map.parent, 'touchmove', this._touchMoveMachine); MM.removeEvent(this.map.parent, 'touchend', this._touchEndMachine); }, updateTouches: function(e) { for (var i = 0; i < e.touches.length; i += 1) { var t = e.touches[i]; if (t.identifier in this.locations) { var l = this.locations[t.identifier]; l.x = t.screenX; l.y = t.screenY; l.scale = e.scale; } else { this.locations[t.identifier] = { scale: e.scale, startPos: { x: t.screenX, y: t.screenY }, x: t.screenX, y: t.screenY, time: new Date().getTime() }; } } }, // Test whether touches are from the same source - // whether this is the same touchmove event. sameTouch: function(event, touch) { return (event && event.touch) && (touch.identifier == event.touch.identifier); }, touchStartMachine: function(e) { this.updateTouches(e); return MM.cancelEvent(e); }, touchMoveMachine: function(e) { switch (e.touches.length) { case 1: this.onPanning(e.touches[0]); break; case 2: this.onPinching(e); break; } this.updateTouches(e); return MM.cancelEvent(e); }, touchEndMachine: function(e) { var now = new Date().getTime(); // round zoom if we're done pinching if (e.touches.length === 0 && this.wasPinching) { this.onPinched(this.lastPinchCenter); } // Look at each changed touch in turn. for (var i = 0; i < e.changedTouches.length; i += 1) { var t = e.changedTouches[i], loc = this.locations[t.identifier]; // if we didn't see this one (bug?) // or if it was consumed by pinching already // just skip to the next one if (!loc || loc.wasPinch) { continue; } // we now know we have an event object and a // matching touch that's just ended. Let's see // what kind of event it is based on how long it // lasted and how far it moved. var pos = { x: t.screenX, y: t.screenY }, time = now - loc.time, travel = MM.Point.distance(pos, loc.startPos); if (travel > this.maxTapDistance) { // we will to assume that the drag has been handled separately } else if (time > this.maxTapTime) { // close in space, but not in time: a hold pos.end = now; pos.duration = time; this.onHold(pos); } else { // close in both time and space: a tap pos.time = now; this.onTap(pos); } } // Weird, sometimes an end event doesn't get thrown // for a touch that nevertheless has disappeared. // Still, this will eventually catch those ids: var validTouchIds = {}; for (var j = 0; j < e.touches.length; j++) { validTouchIds[e.touches[j].identifier] = true; } for (var id in this.locations) { if (!(id in validTouchIds)) { delete validTouchIds[id]; } } return MM.cancelEvent(e); }, onHold: function(hold) { // TODO }, // Handle a tap event - mainly watch for a doubleTap onTap: function(tap) { if (this.taps.length && (tap.time - this.taps[0].time) < this.maxDoubleTapDelay) { this.onDoubleTap(tap); this.taps = []; return; } this.taps = [tap]; }, // Handle a double tap by zooming in a single zoom level to a // round zoom. onDoubleTap: function(tap) { var z = this.map.getZoom(), // current zoom tz = Math.round(z) + 1, // target zoom dz = tz - z; // desired delate // zoom in to a round number var p = new MM.Point(tap.x, tap.y); this.map.zoomByAbout(dz, p); }, // Re-transform the actual map parent's CSS transformation onPanning: function(touch) { var pos = { x: touch.screenX, y: touch.screenY }, prev = this.locations[touch.identifier]; this.map.panBy(pos.x - prev.x, pos.y - prev.y); }, onPinching: function(e) { // use the first two touches and their previous positions var t0 = e.touches[0], t1 = e.touches[1], p0 = new MM.Point(t0.screenX, t0.screenY), p1 = new MM.Point(t1.screenX, t1.screenY), l0 = this.locations[t0.identifier], l1 = this.locations[t1.identifier]; // mark these touches so they aren't used as taps/holds l0.wasPinch = true; l1.wasPinch = true; // scale about the center of these touches var center = MM.Point.interpolate(p0, p1, 0.5); this.map.zoomByAbout( Math.log(e.scale) / Math.LN2 - Math.log(l0.scale) / Math.LN2, center ); // pan from the previous center of these touches var prevCenter = MM.Point.interpolate(l0, l1, 0.5); this.map.panBy(center.x - prevCenter.x, center.y - prevCenter.y); this.wasPinching = true; this.lastPinchCenter = center; }, // When a pinch event ends, round the zoom of the map. onPinched: function(p) { // TODO: easing if (this.options.snapToZoom) { var z = this.map.getZoom(), // current zoom tz = Math.round(z); // target zoom this.map.zoomByAbout(tz - z, p); } this.wasPinching = false; } }; // CallbackManager // --------------- // A general-purpose event binding manager used by `Map` // and `RequestManager` // Construct a new CallbackManager, with an list of // supported events. MM.CallbackManager = function(owner, events) { this.owner = owner; this.callbacks = {}; for (var i = 0; i < events.length; i++) { this.callbacks[events[i]] = []; } }; // CallbackManager does simple event management for modestmaps MM.CallbackManager.prototype = { // The element on which callbacks will be triggered. owner: null, // An object of callbacks in the form // // { event: function } callbacks: null, // Add a callback to this object - where the `event` is a string of // the event name and `callback` is a function. addCallback: function(event, callback) { if (typeof(callback) == 'function' && this.callbacks[event]) { this.callbacks[event].push(callback); } }, // Remove a callback. The given function needs to be equal (`===`) to // the callback added in `addCallback`, so named functions should be // used as callbacks. removeCallback: function(event, callback) { if (typeof(callback) == 'function' && this.callbacks[event]) { var cbs = this.callbacks[event], len = cbs.length; for (var i = 0; i < len; i++) { if (cbs[i] === callback) { cbs.splice(i,1); break; } } } }, // Trigger a callback, passing it an object or string from the second // argument. dispatchCallback: function(event, message) { if(this.callbacks[event]) { for (var i = 0; i < this.callbacks[event].length; i += 1) { try { this.callbacks[event][i](this.owner, message); } catch(e) { //console.log(e); // meh } } } } }; // RequestManager // -------------- // an image loading queue MM.RequestManager = function() { // The loading bay is a document fragment to optimize appending, since // the elements within are invisible. See // [this blog post](http://ejohn.org/blog/dom-documentfragments/). this.loadingBay = document.createDocumentFragment(); this.requestsById = {}; this.openRequestCount = 0; this.maxOpenRequests = 4; this.requestQueue = []; this.callbackManager = new MM.CallbackManager(this, [ 'requestcomplete', 'requesterror']); }; MM.RequestManager.prototype = { // DOM element, hidden, for making sure images dispatch complete events loadingBay: null, // all known requests, by ID requestsById: null, // current pending requests requestQueue: null, // current open requests (children of loadingBay) openRequestCount: null, // the number of open requests permitted at one time, clamped down // because of domain-connection limits. maxOpenRequests: null, // for dispatching 'requestcomplete' callbackManager: null, addCallback: function(event, callback) { this.callbackManager.addCallback(event,callback); }, removeCallback: function(event, callback) { this.callbackManager.removeCallback(event,callback); }, dispatchCallback: function(event, message) { this.callbackManager.dispatchCallback(event,message); }, // Clear everything in the queue by excluding nothing clear: function() { this.clearExcept({}); }, clearRequest: function(id) { if(id in this.requestsById) { delete this.requestsById[id]; } for(var i = 0; i < this.requestQueue.length; i++) { var request = this.requestQueue[i]; if(request && request.id == id) { this.requestQueue[i] = null; } } }, // Clear everything in the queue except for certain keys, specified // by an object of the form // // { key: throwawayvalue } clearExcept: function(validIds) { // clear things from the queue first... for (var i = 0; i < this.requestQueue.length; i++) { var request = this.requestQueue[i]; if (request && !(request.id in validIds)) { this.requestQueue[i] = null; } } // then check the loadingBay... var openRequests = this.loadingBay.childNodes; for (var j = openRequests.length-1; j >= 0; j--) { var img = openRequests[j]; if (!(img.id in validIds)) { this.loadingBay.removeChild(img); this.openRequestCount--; /* console.log(this.openRequestCount + " open requests"); */ img.src = img.coord = img.onload = img.onerror = null; } } // hasOwnProperty protects against prototype additions // > "The standard describes an augmentable Object.prototype. // Ignore standards at your own peril." // -- http://www.yuiblog.com/blog/2006/09/26/for-in-intrigue/ for (var id in this.requestsById) { if (!(id in validIds)) { if (this.requestsById.hasOwnProperty(id)) { var requestToRemove = this.requestsById[id]; // whether we've done the request or not... delete this.requestsById[id]; if (requestToRemove !== null) { requestToRemove = requestToRemove.id = requestToRemove.coord = requestToRemove.url = null; } } } } }, // Given a tile id, check whether the RequestManager is currently // requesting it and waiting for the result. hasRequest: function(id) { return (id in this.requestsById); }, // * TODO: remove dependency on coord (it's for sorting, maybe call it data?) // * TODO: rename to requestImage once it's not tile specific requestTile: function(id, coord, url) { if (!(id in this.requestsById)) { var request = { id: id, coord: coord.copy(), url: url }; // if there's no url just make sure we don't request this image again this.requestsById[id] = request; if (url) { this.requestQueue.push(request); /* console.log(this.requestQueue.length + ' pending requests'); */ } } }, getProcessQueue: function() { // let's only create this closure once... if (!this._processQueue) { var theManager = this; this._processQueue = function() { theManager.processQueue(); }; } return this._processQueue; }, // Select images from the `requestQueue` and create image elements for // them, attaching their load events to the function returned by // `this.getLoadComplete()` so that they can be added to the map. processQueue: function(sortFunc) { // When the request queue fills up beyond 8, start sorting the // requests so that spiral-loading or another pattern can be used. if (sortFunc && this.requestQueue.length > 8) { this.requestQueue.sort(sortFunc); } while (this.openRequestCount < this.maxOpenRequests && this.requestQueue.length > 0) { var request = this.requestQueue.pop(); if (request) { this.openRequestCount++; /* console.log(this.openRequestCount + ' open requests'); */ // JSLitmus benchmark shows createElement is a little faster than // new Image() in Firefox and roughly the same in Safari: // http://tinyurl.com/y9wz2jj http://tinyurl.com/yes6rrt var img = document.createElement('img'); // FIXME: id is technically not unique in document if there // are two Maps but toKey is supposed to be fast so we're trying // to avoid a prefix ... hence we can't use any calls to // `document.getElementById()` to retrieve images img.id = request.id; img.style.position = 'absolute'; // * FIXME: store this elsewhere to avoid scary memory leaks? // * FIXME: call this 'data' not 'coord' so that RequestManager is less Tile-centric? img.coord = request.coord; // add it to the DOM in a hidden layer, this is a bit of a hack, but it's // so that the event we get in image.onload has srcElement assigned in IE6 this.loadingBay.appendChild(img); // set these before img.src to avoid missing an img that's already cached img.onload = img.onerror = this.getLoadComplete(); img.src = request.url; // keep things tidy request = request.id = request.coord = request.url = null; } } }, _loadComplete: null, // Get the singleton `_loadComplete` function that is called on image // load events, either removing them from the queue and dispatching an // event to add them to the map, or deleting them if the image failed // to load. getLoadComplete: function() { // let's only create this closure once... if (!this._loadComplete) { var theManager = this; this._loadComplete = function(e) { // this is needed because we don't use MM.addEvent for images e = e || window.event; // srcElement for IE, target for FF, Safari etc. var img = e.srcElement || e.target; // unset these straight away so we don't call this twice img.onload = img.onerror = null; // pull it back out of the (hidden) DOM // so that draw will add it correctly later theManager.loadingBay.removeChild(img); theManager.openRequestCount--; delete theManager.requestsById[img.id]; /* console.log(theManager.openRequestCount + ' open requests'); */ // NB:- complete is also true onerror if we got a 404 if (e.type === 'load' && (img.complete || (img.readyState && img.readyState == 'complete'))) { theManager.dispatchCallback('requestcomplete', img); } else { // if it didn't finish clear its src to make sure it // really stops loading // FIXME: we'll never retry because this id is still // in requestsById - is that right? theManager.dispatchCallback('requesterror', img.src); img.src = null; } // keep going in the same order // use `setTimeout()` to avoid the IE recursion limit, see // http://cappuccino.org/discuss/2010/03/01/internet-explorer-global-variables-and-stack-overflows/ // and https://github.com/stamen/modestmaps-js/issues/12 setTimeout(theManager.getProcessQueue(), 0); }; } return this._loadComplete; } }; // Layer MM.Layer = function(provider, parent) { this.parent = parent || document.createElement('div'); this.parent.style.cssText = 'position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; margin: 0; padding: 0; z-index: 0'; this.levels = {}; this.requestManager = new MM.RequestManager(); this.requestManager.addCallback('requestcomplete', this.getTileComplete()); if (provider) { this.setProvider(provider); } }; MM.Layer.prototype = { map: null, // TODO: remove parent: null, tiles: null, levels: null, requestManager: null, tileCacheSize: null, maxTileCacheSize: null, provider: null, recentTiles: null, recentTilesById: {}, enablePyramidLoading: false, _tileComplete: null, getTileComplete: function() { if (!this._tileComplete) { var theLayer = this; this._tileComplete = function(manager, tile) { // cache the tile itself: theLayer.tiles[tile.id] = tile; theLayer.tileCacheSize++; // also keep a record of when we last touched this tile: var record = { id: tile.id, lastTouchedTime: new Date().getTime() }; theLayer.recentTilesById[tile.id] = record; theLayer.recentTiles.push(record); // position this tile (avoids a full draw() call): theLayer.positionTile(tile); }; } return this._tileComplete; }, draw: function() { // if we're in between zoom levels, we need to choose the nearest: var baseZoom = Math.round(this.map.coordinate.zoom); // these are the top left and bottom right tile coordinates // we'll be loading everything in between: var startCoord = this.map.pointCoordinate(new MM.Point(0,0)) .zoomTo(baseZoom).container(); var endCoord = this.map.pointCoordinate(this.map.dimensions) .zoomTo(baseZoom).container().right().down(); // tiles with invalid keys will be removed from visible levels // requests for tiles with invalid keys will be canceled // (this object maps from a tile key to a boolean) var validTileKeys = { }; // make sure we have a container for tiles in the current level var levelElement = this.createOrGetLevel(startCoord.zoom); // use this coordinate for generating keys, parents and children: var tileCoord = startCoord.copy(); for (tileCoord.column = startCoord.column; tileCoord.column <= endCoord.column; tileCoord.column++) { for (tileCoord.row = startCoord.row; tileCoord.row <= endCoord.row; tileCoord.row++) { var validKeys = this.inventoryVisibleTile(levelElement, tileCoord); while (validKeys.length) { validTileKeys[validKeys.pop()] = true; } } } // i from i to zoom-5 are levels that would be scaled too big, // i from zoom + 2 to levels. length are levels that would be // scaled too small (and tiles would be too numerous) for (var name in this.levels) { if (this.levels.hasOwnProperty(name)) { var zoom = parseInt(name,10); if (zoom >= startCoord.zoom - 5 && zoom < startCoord.zoom + 2) { continue; } var level = this.levels[name]; level.style.display = 'none'; var visibleTiles = this.tileElementsInLevel(level); while (visibleTiles.length) { this.provider.releaseTile(visibleTiles[0].coord); this.requestManager.clearRequest(visibleTiles[0].coord.toKey()); level.removeChild(visibleTiles[0]); visibleTiles.shift(); } } } // levels we want to see, if they have tiles in validTileKeys var minLevel = startCoord.zoom - 5; var maxLevel = startCoord.zoom + 2; for (var z = minLevel; z < maxLevel; z++) { this.adjustVisibleLevel(this.levels[z], z, validTileKeys); } // cancel requests that aren't visible: this.requestManager.clearExcept(validTileKeys); // get newly requested tiles, sort according to current view: this.requestManager.processQueue(this.getCenterDistanceCompare()); // make sure we don't have too much stuff: this.checkCache(); }, // For a given tile coordinate in a given level element, ensure that it's // correctly represented in the DOM including potentially-overlapping // parent and child tiles for pyramid loading. // // Return a list of valid (i.e. loadable?) tile keys. inventoryVisibleTile: function(layer_element, tile_coord) { var tile_key = tile_coord.toKey(), valid_tile_keys = [tile_key]; // Check that the needed tile already exists someplace - add it to the DOM if it does. if (tile_key in this.tiles) { var tile = this.tiles[tile_key]; // ensure it's in the DOM: if (tile.parentNode != layer_element) { layer_element.appendChild(tile); // if the provider implements reAddTile(), call it if ("reAddTile" in this.provider) { this.provider.reAddTile(tile_key, tile_coord, tile); } } return valid_tile_keys; } // Check that the needed tile has even been requested at all. if (!this.requestManager.hasRequest(tile_key)) { var tileToRequest = this.provider.getTile(tile_coord); if (typeof tileToRequest == 'string') { this.addTileImage(tile_key, tile_coord, tileToRequest); // tile must be truish } else if (tileToRequest) { this.addTileElement(tile_key, tile_coord, tileToRequest); } } // look for a parent tile in our image cache var tileCovered = false; var maxStepsOut = tile_coord.zoom; for (var pz = 1; pz <= maxStepsOut; pz++) { var parent_coord = tile_coord.zoomBy(-pz).container(); var parent_key = parent_coord.toKey(); if (this.enablePyramidLoading) { // mark all parent tiles valid valid_tile_keys.push(parent_key); var parentLevel = this.createOrGetLevel(parent_coord.zoom); //parentLevel.coordinate = parent_coord.copy(); if (parent_key in this.tiles) { var parentTile = this.tiles[parent_key]; if (parentTile.parentNode != parentLevel) { parentLevel.appendChild(parentTile); } } else if (!this.requestManager.hasRequest(parent_key)) { // force load of parent tiles we don't already have var tileToAdd = this.provider.getTile(parent_coord); if (typeof tileToAdd == 'string') { this.addTileImage(parent_key, parent_coord, tileToAdd); } else { this.addTileElement(parent_key, parent_coord, tileToAdd); } } } else { // only mark it valid if we have it already if (parent_key in this.tiles) { valid_tile_keys.push(parent_key); tileCovered = true; break; } } } // if we didn't find a parent, look at the children: if (!tileCovered && !this.enablePyramidLoading) { var child_coord = tile_coord.zoomBy(1); // mark everything valid whether or not we have it: valid_tile_keys.push(child_coord.toKey()); child_coord.column += 1; valid_tile_keys.push(child_coord.toKey()); child_coord.row += 1; valid_tile_keys.push(child_coord.toKey()); child_coord.column -= 1; valid_tile_keys.push(child_coord.toKey()); } return valid_tile_keys; }, tileElementsInLevel: function(level) { // this is somewhat future proof, we're looking for DOM elements // not necessarily elements var tiles = []; for (var tile = level.firstChild; tile; tile = tile.nextSibling) { if (tile.nodeType == 1) { tiles.push(tile); } } return tiles; }, /** * For a given level, adjust visibility as a whole and discard individual * tiles based on values in valid_tile_keys from inventoryVisibleTile(). */ adjustVisibleLevel: function(level, zoom, valid_tile_keys) { // for tracking time of tile usage: var now = new Date().getTime(); if (!level) { // no tiles for this level yet return; } var scale = 1; var theCoord = this.map.coordinate.copy(); if (level.childNodes.length > 0) { level.style.display = 'block'; scale = Math.pow(2, this.map.coordinate.zoom - zoom); theCoord = theCoord.zoomTo(zoom); } else { level.style.display = 'none'; } var tileWidth = this.map.tileSize.x * scale; var tileHeight = this.map.tileSize.y * scale; var center = new MM.Point(this.map.dimensions.x/2, this.map.dimensions.y/2); var tiles = this.tileElementsInLevel(level); while (tiles.length) { var tile = tiles.pop(); if (!valid_tile_keys[tile.id]) { this.provider.releaseTile(tile.coord); this.requestManager.clearRequest(tile.coord.toKey()); level.removeChild(tile); } else { // log last-touched-time of currently cached tiles this.recentTilesById[tile.id].lastTouchedTime = now; } } // position tiles MM.moveElement(level, { x: Math.round(center.x - (theCoord.column * tileWidth)), y: Math.round(center.y - (theCoord.row * tileHeight)), scale: scale, // TODO: pass only scale or only w/h // width: this.map.tileSize.x, width: Math.pow(2, theCoord.zoom) * this.map.tileSize.x, height: Math.pow(2, theCoord.zoom) * this.map.tileSize.y }); }, createOrGetLevel: function(zoom) { if (zoom in this.levels) { return this.levels[zoom]; } //console.log('creating level ' + zoom); var level = document.createElement('div'); level.id = this.parent.id+'-zoom-'+zoom; level.style.cssText = this.parent.style.cssText; level.style.zIndex = zoom; this.parent.appendChild(level); this.levels[zoom] = level; return level; }, addTileImage: function(key, coord, url) { this.requestManager.requestTile(key, coord, url); }, addTileElement: function(key, coordinate, element) { // Expected in draw() element.id = key; element.coord = coordinate.copy(); // cache the tile itself: this.tiles[key] = element; this.tileCacheSize++; // also keep a record of when we last touched this tile: var record = { id: key, lastTouchedTime: new Date().getTime() }; this.recentTilesById[key] = record; this.recentTiles.push(record); this.positionTile(element); }, positionTile: function(tile) { // position this tile (avoids a full draw() call): var theCoord = this.map.coordinate.zoomTo(tile.coord.zoom); // Start tile positioning and prevent drag for modern browsers tile.style.cssText = 'position:absolute;-webkit-user-select: none;-webkit-user-drag: none;-moz-user-drag: none;'; // Prevent drag for IE tile.ondragstart = function() { return false; }; var tx = tile.coord.column * this.map.tileSize.x; var ty = tile.coord.row * this.map.tileSize.y; // TODO: pass only scale or only w/h MM.moveElement(tile, { x: Math.round(tx), y: Math.round(ty), width: this.map.tileSize.x, height: this.map.tileSize.y }); // add tile to its level var theLevel = this.levels[tile.coord.zoom]; theLevel.appendChild(tile); // Support style transition if available. tile.className = 'map-tile-loaded'; // ensure the level is visible if it's still the current level if (Math.round(this.map.coordinate.zoom) == tile.coord.zoom) { theLevel.style.display = 'block'; } // request a lazy redraw of all levels // this will remove tiles that were only visible // to cover this tile while it loaded: this.requestRedraw(); }, _redrawTimer: undefined, requestRedraw: function() { // we'll always draw within 1 second of this request, // sometimes faster if there's already a pending redraw // this is used when a new tile arrives so that we clear // any parent/child tiles that were only being displayed // until the tile loads at the right zoom level if (!this._redrawTimer) { this._redrawTimer = setTimeout(this.getRedraw(), 1000); } }, _redraw: null, getRedraw: function() { // let's only create this closure once... if (!this._redraw) { var theLayer = this; this._redraw = function() { theLayer.draw(); theLayer._redrawTimer = 0; }; } return this._redraw; }, numTilesOnScreen: function() { var tileCount = 0; for (var name in this.levels) { if (this.levels.hasOwnProperty(name)) { var level = this.levels[name]; tileCount += this.tileElementsInLevel(level).length; } } return tileCount; }, // keeps cache below max size // (called every time we receive a new tile and add it to the cache) checkCache: function() { var maxTiles = Math.max(this.numTilesOnScreen(), this.maxTileCacheSize); if (this.tileCacheSize > maxTiles) { // sort from newest (highest) to oldest (lowest) this.recentTiles.sort(function(t1, t2) { return t2.lastTouchedTime < t1.lastTouchedTime ? -1 : t2.lastTouchedTime > t1.lastTouchedTime ? 1 : 0; }); } while (this.recentTiles.length && this.tileCacheSize > maxTiles) { // delete the oldest record var tileRecord = this.recentTiles.pop(); var now = new Date().getTime(); delete this.recentTilesById[tileRecord.id]; //window.console.log('removing ' + tileRecord.id + // ' last seen ' + (now-tileRecord.lastTouchedTime) + 'ms ago'); // now actually remove it from the cache... var tile = this.tiles[tileRecord.id]; if (tile.parentNode) { // I'm leaving this uncommented for now but you should never see it: alert("Gah: trying to removing cached tile even though it's still in the DOM"); } else { delete this.tiles[tileRecord.id]; this.tileCacheSize--; } } }, setProvider: function(newProvider) { var firstProvider = (this.provider === null); // if we already have a provider the we'll need to // clear the DOM, cancel requests and redraw if (!firstProvider) { this.requestManager.clear(); for (var name in this.levels) { if (this.levels.hasOwnProperty(name)) { var level = this.levels[name]; while (level.firstChild) { this.provider.releaseTile(level.firstChild.coord); level.removeChild(level.firstChild); } } } } // first provider or not we'll init/reset some values... this.tiles = {}; this.tileCacheSize = 0; this.maxTileCacheSize = 64; this.recentTilesById = {}; this.recentTiles = []; // for later: check geometry of old provider and set a new coordinate center // if needed (now? or when?) this.provider = newProvider; if (!firstProvider) { this.draw(); } }, // compares manhattan distance from center of // requested tiles to current map center // NB:- requested tiles are *popped* from queue, so we do a descending sort getCenterDistanceCompare: function() { var theCoord = this.map.coordinate.zoomTo(Math.round(this.map.coordinate.zoom)); return function(r1, r2) { if (r1 && r2) { var c1 = r1.coord; var c2 = r2.coord; if (c1.zoom == c2.zoom) { var ds1 = Math.abs(theCoord.row - c1.row - 0.5) + Math.abs(theCoord.column - c1.column - 0.5); var ds2 = Math.abs(theCoord.row - c2.row - 0.5) + Math.abs(theCoord.column - c2.column - 0.5); return ds1 < ds2 ? 1 : ds1 > ds2 ? -1 : 0; } else { return c1.zoom < c2.zoom ? 1 : c1.zoom > c2.zoom ? -1 : 0; } } return r1 ? 1 : r2 ? -1 : 0; }; }, // Remove this layer from the DOM, cancel all of its requests // and unbind any callbacks that are bound to it. destroy: function() { this.requestManager.clear(); this.requestManager.removeCallback('requestcomplete', this.getTileComplete()); // TODO: does requestManager need a destroy function too? this.provider = null; // If this layer was ever attached to the DOM, detach it. if (this.parent.parentNode) { this.parent.parentNode.removeChild(this.parent); } this.map = null; } }; // Map // Instance of a map intended for drawing to a div. // // * `parent` (required DOM element) // Can also be an ID of a DOM element // * `layerOrLayers` (required MM.Layer or Array of MM.Layers) // each one must implement draw(), destroy(), have a .parent DOM element and a .map property // (an array of URL templates or MM.MapProviders is also acceptable) // * `dimensions` (optional Point) // Size of map to create // * `eventHandlers` (optional Array) // If empty or null MouseHandler will be used // Otherwise, each handler will be called with init(map) MM.Map = function(parent, layerOrLayers, dimensions, eventHandlers) { if (typeof parent == 'string') { parent = document.getElementById(parent); if (!parent) { throw 'The ID provided to modest maps could not be found.'; } } this.parent = parent; // we're no longer adding width and height to parent.style but we still // need to enforce padding, overflow and position otherwise everything screws up // TODO: maybe console.warn if the current values are bad? this.parent.style.padding = '0'; this.parent.style.overflow = 'hidden'; var position = MM.getStyle(this.parent, 'position'); if (position != 'relative' && position != 'absolute') { this.parent.style.position = 'relative'; } this.layers = []; if(!(layerOrLayers instanceof Array)) { layerOrLayers = [ layerOrLayers ]; } for (var i = 0; i < layerOrLayers.length; i++) { this.addLayer(layerOrLayers[i]); } // default to Google-y Mercator style maps this.projection = new MM.MercatorProjection(0, MM.deriveTransformation(-Math.PI, Math.PI, 0, 0, Math.PI, Math.PI, 1, 0, -Math.PI, -Math.PI, 0, 1)); this.tileSize = new MM.Point(256, 256); // default 0-18 zoom level // with infinite horizontal pan and clamped vertical pan this.coordLimits = [ new MM.Coordinate(0,-Infinity,0), // top left outer new MM.Coordinate(1,Infinity,0).zoomTo(18) // bottom right inner ]; // eyes towards null island this.coordinate = new MM.Coordinate(0.5, 0.5, 0); // if you don't specify dimensions we assume you want to fill the parent // unless the parent has no w/h, in which case we'll still use a default if (!dimensions) { dimensions = new MM.Point(this.parent.offsetWidth, this.parent.offsetHeight); this.autoSize = true; // use destroy to get rid of this handler from the DOM MM.addEvent(window, 'resize', this.windowResize()); } else { this.autoSize = false; // don't call setSize here because it calls draw() this.parent.style.width = Math.round(dimensions.x) + 'px'; this.parent.style.height = Math.round(dimensions.y) + 'px'; } this.dimensions = dimensions; this.callbackManager = new MM.CallbackManager(this, [ 'zoomed', 'panned', 'centered', 'extentset', 'resized', 'drawn' ]); // set up handlers last so that all required attributes/functions are in place if needed if (eventHandlers === undefined) { this.eventHandlers = [ new MM.MouseHandler(this), new MM.TouchHandler(this) ]; } else { this.eventHandlers = eventHandlers; if (eventHandlers instanceof Array) { for (var j = 0; j < eventHandlers.length; j++) { eventHandlers[j].init(this); } } } }; MM.Map.prototype = { parent: null, // DOM Element dimensions: null, // MM.Point with x/y size of parent element projection: null, // MM.Projection of first known layer coordinate: null, // Center of map MM.Coordinate with row/column/zoom tileSize: null, // MM.Point with x/y size of tiles coordLimits: null, // Array of [ topLeftOuter, bottomLeftInner ] MM.Coordinates layers: null, // Array of MM.Layer (interface = .draw(), .destroy(), .parent and .map) callbackManager: null, // MM.CallbackManager, handles map events eventHandlers: null, // Array of interaction handlers, just a MM.MouseHandler by default autoSize: null, // Boolean, true if we have a window resize listener toString: function() { return 'Map(#' + this.parent.id + ')'; }, // callbacks... addCallback: function(event, callback) { this.callbackManager.addCallback(event, callback); return this; }, removeCallback: function(event, callback) { this.callbackManager.removeCallback(event, callback); return this; }, dispatchCallback: function(event, message) { this.callbackManager.dispatchCallback(event, message); return this; }, windowResize: function() { if (!this._windowResize) { var theMap = this; this._windowResize = function(event) { // don't call setSize here because it sets parent.style.width/height // and setting the height breaks percentages and default styles theMap.dimensions = new MM.Point(theMap.parent.offsetWidth, theMap.parent.offsetHeight); theMap.draw(); theMap.dispatchCallback('resized', [theMap.dimensions]); }; } return this._windowResize; }, // A convenience function to restrict interactive zoom ranges. // (you should also adjust map provider to restrict which tiles get loaded, // or modify map.coordLimits and provider.tileLimits for finer control) setZoomRange: function(minZoom, maxZoom) { this.coordLimits[0] = this.coordLimits[0].zoomTo(minZoom); this.coordLimits[1] = this.coordLimits[1].zoomTo(maxZoom); }, // zooming zoomBy: function(zoomOffset) { this.coordinate = this.enforceLimits(this.coordinate.zoomBy(zoomOffset)); MM.getFrame(this.getRedraw()); this.dispatchCallback('zoomed', zoomOffset); return this; }, zoomIn: function() { return this.zoomBy(1); }, zoomOut: function() { return this.zoomBy(-1); }, setZoom: function(z) { return this.zoomBy(z - this.coordinate.zoom); }, zoomByAbout: function(zoomOffset, point) { var location = this.pointLocation(point); this.coordinate = this.enforceLimits(this.coordinate.zoomBy(zoomOffset)); var newPoint = this.locationPoint(location); this.dispatchCallback('zoomed', zoomOffset); return this.panBy(point.x - newPoint.x, point.y - newPoint.y); }, // panning panBy: function(dx, dy) { this.coordinate.column -= dx / this.tileSize.x; this.coordinate.row -= dy / this.tileSize.y; this.coordinate = this.enforceLimits(this.coordinate); // Defer until the browser is ready to draw. MM.getFrame(this.getRedraw()); this.dispatchCallback('panned', [dx, dy]); return this; }, panLeft: function() { return this.panBy(100, 0); }, panRight: function() { return this.panBy(-100, 0); }, panDown: function() { return this.panBy(0, -100); }, panUp: function() { return this.panBy(0, 100); }, // positioning setCenter: function(location) { return this.setCenterZoom(location, this.coordinate.zoom); }, setCenterZoom: function(location, zoom) { this.coordinate = this.projection.locationCoordinate(location).zoomTo(parseFloat(zoom) || 0); MM.getFrame(this.getRedraw()); this.dispatchCallback('centered', [location, zoom]); return this; }, setExtent: function(locations, precise) { // coerce locations to an array if it's a Extent instance if (locations instanceof MM.Extent) { locations = locations.toArray(); } var TL, BR; for (var i = 0; i < locations.length; i++) { var coordinate = this.projection.locationCoordinate(locations[i]); if (TL) { TL.row = Math.min(TL.row, coordinate.row); TL.column = Math.min(TL.column, coordinate.column); TL.zoom = Math.min(TL.zoom, coordinate.zoom); BR.row = Math.max(BR.row, coordinate.row); BR.column = Math.max(BR.column, coordinate.column); BR.zoom = Math.max(BR.zoom, coordinate.zoom); } else { TL = coordinate.copy(); BR = coordinate.copy(); } } var width = this.dimensions.x + 1; var height = this.dimensions.y + 1; // multiplication factor between horizontal span and map width var hFactor = (BR.column - TL.column) / (width / this.tileSize.x); // multiplication factor expressed as base-2 logarithm, for zoom difference var hZoomDiff = Math.log(hFactor) / Math.log(2); // possible horizontal zoom to fit geographical extent in map width var hPossibleZoom = TL.zoom - (precise ? hZoomDiff : Math.ceil(hZoomDiff)); // multiplication factor between vertical span and map height var vFactor = (BR.row - TL.row) / (height / this.tileSize.y); // multiplication factor expressed as base-2 logarithm, for zoom difference var vZoomDiff = Math.log(vFactor) / Math.log(2); // possible vertical zoom to fit geographical extent in map height var vPossibleZoom = TL.zoom - (precise ? vZoomDiff : Math.ceil(vZoomDiff)); // initial zoom to fit extent vertically and horizontally var initZoom = Math.min(hPossibleZoom, vPossibleZoom); // additionally, make sure it's not outside the boundaries set by map limits initZoom = Math.min(initZoom, this.coordLimits[1].zoom); initZoom = Math.max(initZoom, this.coordLimits[0].zoom); // coordinate of extent center var centerRow = (TL.row + BR.row) / 2; var centerColumn = (TL.column + BR.column) / 2; var centerZoom = TL.zoom; this.coordinate = new MM.Coordinate(centerRow, centerColumn, centerZoom).zoomTo(initZoom); this.draw(); // draw calls enforceLimits // (if you switch to getFrame, call enforceLimits first) this.dispatchCallback('extentset', locations); return this; }, // Resize the map's container `