mapbox-gl
Version:
A WebGL interactive maps library
413 lines (341 loc) • 12.3 kB
JavaScript
'use strict';
var LngLat = require('./lng_lat'),
Point = require('point-geometry'),
Coordinate = require('./coordinate'),
wrap = require('../util/util').wrap,
interp = require('../util/interpolate'),
glmatrix = require('gl-matrix');
var vec4 = glmatrix.vec4,
mat4 = glmatrix.mat4,
mat2 = glmatrix.mat2;
module.exports = Transform;
/*
* A single transform, generally used for a single tile to be
* scaled, rotated, and zoomed.
*
* @param {number} minZoom
* @param {number} maxZoom
* @private
*/
function Transform(minZoom, maxZoom) {
this.tileSize = 512; // constant
this._minZoom = minZoom || 0;
this._maxZoom = maxZoom || 22;
this.latRange = [-85.05113, 85.05113];
this.width = 0;
this.height = 0;
this._center = new LngLat(0, 0);
this.zoom = 0;
this.angle = 0;
this._altitude = 1.5;
this._pitch = 0;
this._unmodified = true;
}
Transform.prototype = {
get minZoom() { return this._minZoom; },
set minZoom(zoom) {
if (this._minZoom === zoom) return;
this._minZoom = zoom;
this.zoom = Math.max(this.zoom, zoom);
},
get maxZoom() { return this._maxZoom; },
set maxZoom(zoom) {
if (this._maxZoom === zoom) return;
this._maxZoom = zoom;
this.zoom = Math.min(this.zoom, zoom);
},
get worldSize() {
return this.tileSize * this.scale;
},
get centerPoint() {
return this.size._div(2);
},
get size() {
return new Point(this.width, this.height);
},
get bearing() {
return -this.angle / Math.PI * 180;
},
set bearing(bearing) {
var b = -wrap(bearing, -180, 180) * Math.PI / 180;
if (this.angle === b) return;
this._unmodified = false;
this.angle = b;
this._calcProjMatrix();
// 2x2 matrix for rotating points
this.rotationMatrix = mat2.create();
mat2.rotate(this.rotationMatrix, this.rotationMatrix, this.angle);
},
get pitch() {
return this._pitch / Math.PI * 180;
},
set pitch(pitch) {
var p = Math.min(60, pitch) / 180 * Math.PI;
if (this._pitch === p) return;
this._unmodified = false;
this._pitch = p;
this._calcProjMatrix();
},
get altitude() {
return this._altitude;
},
set altitude(altitude) {
var a = Math.max(0.75, altitude);
if (this._altitude === a) return;
this._unmodified = false;
this._altitude = a;
this._calcProjMatrix();
},
get zoom() { return this._zoom; },
set zoom(zoom) {
var z = Math.min(Math.max(zoom, this.minZoom), this.maxZoom);
if (this._zoom === z) return;
this._unmodified = false;
this._zoom = z;
this.scale = this.zoomScale(z);
this.tileZoom = Math.floor(z);
this.zoomFraction = z - this.tileZoom;
this._calcProjMatrix();
this._constrain();
},
get center() { return this._center; },
set center(center) {
if (center.lat === this._center.lat && center.lng === this._center.lng) return;
this._unmodified = false;
this._center = center;
this._calcProjMatrix();
this._constrain();
},
resize: function(width, height) {
this.width = width;
this.height = height;
// The extrusion matrix
this.exMatrix = mat4.create();
mat4.ortho(this.exMatrix, 0, width, height, 0, 0, -1);
this._calcProjMatrix();
this._constrain();
},
get unmodified() { return this._unmodified; },
zoomScale: function(zoom) { return Math.pow(2, zoom); },
scaleZoom: function(scale) { return Math.log(scale) / Math.LN2; },
project: function(lnglat, worldSize) {
return new Point(
this.lngX(lnglat.lng, worldSize),
this.latY(lnglat.lat, worldSize));
},
unproject: function(point, worldSize) {
return new LngLat(
this.xLng(point.x, worldSize),
this.yLat(point.y, worldSize));
},
get x() { return this.lngX(this.center.lng); },
get y() { return this.latY(this.center.lat); },
get point() { return new Point(this.x, this.y); },
/**
* latitude to absolute x coord
* @param {number} lon
* @param {number} [worldSize=this.worldSize]
* @returns {number} pixel coordinate
* @private
*/
lngX: function(lng, worldSize) {
return (180 + lng) * (worldSize || this.worldSize) / 360;
},
/**
* latitude to absolute y coord
* @param {number} lat
* @param {number} [worldSize=this.worldSize]
* @returns {number} pixel coordinate
* @private
*/
latY: function(lat, worldSize) {
var y = 180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360));
return (180 - y) * (worldSize || this.worldSize) / 360;
},
xLng: function(x, worldSize) {
return x * 360 / (worldSize || this.worldSize) - 180;
},
yLat: function(y, worldSize) {
var y2 = 180 - y * 360 / (worldSize || this.worldSize);
return 360 / Math.PI * Math.atan(Math.exp(y2 * Math.PI / 180)) - 90;
},
panBy: function(offset) {
var point = this.centerPoint._add(offset);
this.center = this.pointLocation(point);
},
setLocationAtPoint: function(lnglat, point) {
var c = this.locationCoordinate(lnglat);
var coordAtPoint = this.pointCoordinate(point);
var coordCenter = this.pointCoordinate(this.centerPoint);
var translate = coordAtPoint._sub(c);
this._unmodified = false;
this.center = this.coordinateLocation(coordCenter._sub(translate));
},
/**
* Given a location, return the screen point that corresponds to it
* @param {LngLat} lnglat location
* @returns {Point} screen point
* @private
*/
locationPoint: function(lnglat) {
return this.coordinatePoint(this.locationCoordinate(lnglat));
},
/**
* Given a point on screen, return its lnglat
* @param {Point} p screen point
* @returns {LngLat} lnglat location
* @private
*/
pointLocation: function(p) {
return this.coordinateLocation(this.pointCoordinate(p));
},
/**
* Given a geographical lnglat, return an unrounded
* coordinate that represents it at this transform's zoom level and
* worldsize.
* @param {LngLat} lnglat
* @returns {Coordinate}
* @private
*/
locationCoordinate: function(lnglat) {
var k = this.zoomScale(this.tileZoom) / this.worldSize,
ll = LngLat.convert(lnglat);
return new Coordinate(
this.lngX(ll.lng) * k,
this.latY(ll.lat) * k,
this.tileZoom);
},
/**
* Given a Coordinate, return its geographical position.
* @param {Coordinate} coord
* @returns {LngLat} lnglat
* @private
*/
coordinateLocation: function(coord) {
var worldSize = this.zoomScale(coord.zoom);
return new LngLat(
this.xLng(coord.column, worldSize),
this.yLat(coord.row, worldSize));
},
pointCoordinate: function(p, targetZ) {
if (targetZ === undefined) targetZ = 0;
var matrix = this.coordinatePointMatrix(this.tileZoom);
mat4.invert(matrix, matrix);
if (!matrix) throw new Error("failed to invert matrix");
// since we don't know the correct projected z value for the point,
// unproject two points to get a line and then find the point on that
// line with z=0
var coord0 = [p.x, p.y, 0, 1];
var coord1 = [p.x, p.y, 1, 1];
vec4.transformMat4(coord0, coord0, matrix);
vec4.transformMat4(coord1, coord1, matrix);
var w0 = coord0[3];
var w1 = coord1[3];
var x0 = coord0[0] / w0;
var x1 = coord1[0] / w1;
var y0 = coord0[1] / w0;
var y1 = coord1[1] / w1;
var z0 = coord0[2] / w0;
var z1 = coord1[2] / w1;
var t = z0 === z1 ? 0 : (targetZ - z0) / (z1 - z0);
return new Coordinate(
interp(x0, x1, t),
interp(y0, y1, t),
this.tileZoom);
},
/**
* Given a coordinate, return the screen point that corresponds to it
* @param {Coordinate} coord
* @returns {Point} screen point
* @private
*/
coordinatePoint: function(coord) {
var matrix = this.coordinatePointMatrix(coord.zoom);
var p = [coord.column, coord.row, 0, 1];
vec4.transformMat4(p, p, matrix);
return new Point(p[0] / p[3], p[1] / p[3]);
},
coordinatePointMatrix: function(z) {
var proj = mat4.copy(new Float64Array(16), this.projMatrix);
var scale = this.worldSize / this.zoomScale(z);
mat4.scale(proj, proj, [scale, scale, 1]);
mat4.multiply(proj, this.getPixelMatrix(), proj);
return proj;
},
/**
* converts gl coordinates -1..1 to pixels 0..width
* @returns {Object} matrix
* @private
*/
getPixelMatrix: function() {
var m = mat4.create();
mat4.scale(m, m, [this.width / 2, -this.height / 2, 1]);
mat4.translate(m, m, [1, -1, 0]);
return m;
},
_constrain: function() {
if (!this.center || !this.width || !this.height || this._constraining) return;
this._constraining = true;
var minY, maxY, minX, maxX, sy, sx, x2, y2,
size = this.size,
unmodified = this._unmodified;
if (this.latRange) {
minY = this.latY(this.latRange[1]);
maxY = this.latY(this.latRange[0]);
sy = maxY - minY < size.y ? size.y / (maxY - minY) : 0;
}
if (this.lngRange) {
minX = this.lngX(this.lngRange[0]);
maxX = this.lngX(this.lngRange[1]);
sx = maxX - minX < size.x ? size.x / (maxX - minX) : 0;
}
// how much the map should scale to fit the screen into given latitude/longitude ranges
var s = Math.max(sx || 0, sy || 0);
if (s) {
this.center = this.unproject(new Point(
sx ? (maxX + minX) / 2 : this.x,
sy ? (maxY + minY) / 2 : this.y));
this.zoom += this.scaleZoom(s);
this._unmodified = unmodified;
this._constraining = false;
return;
}
if (this.latRange) {
var y = this.y,
h2 = size.y / 2;
if (y - h2 < minY) y2 = minY + h2;
if (y + h2 > maxY) y2 = maxY - h2;
}
if (this.lngRange) {
var x = this.x,
w2 = size.x / 2;
if (x - w2 < minX) x2 = minX + w2;
if (x + w2 > maxX) x2 = maxX - w2;
}
// pan the map if the screen goes off the range
if (x2 !== undefined || y2 !== undefined) {
this.center = this.unproject(new Point(
x2 !== undefined ? x2 : this.x,
y2 !== undefined ? y2 : this.y));
}
this._unmodified = unmodified;
this._constraining = false;
},
_calcProjMatrix: function() {
var m = new Float64Array(16);
// Find the distance from the center point to the center top in altitude units using law of sines.
var halfFov = Math.atan(0.5 / this.altitude);
var topHalfSurfaceDistance = Math.sin(halfFov) * this.altitude / Math.sin(Math.PI / 2 - this._pitch - halfFov);
// Calculate z value of the farthest fragment that should be rendered.
var farZ = Math.cos(Math.PI / 2 - this._pitch) * topHalfSurfaceDistance + this.altitude;
mat4.perspective(m, 2 * Math.atan((this.height / 2) / this.altitude), this.width / this.height, 0.1, farZ);
mat4.translate(m, m, [0, 0, -this.altitude]);
// After the rotateX, z values are in pixel units. Convert them to
// altitude unites. 1 altitude unit = the screen height.
mat4.scale(m, m, [1, -1, 1 / this.height]);
mat4.rotateX(m, m, this._pitch);
mat4.rotateZ(m, m, this.angle);
mat4.translate(m, m, [-this.x, -this.y, 0]);
this.projMatrix = m;
}
};