mapbox-gl
Version:
A WebGL interactive maps library
474 lines (399 loc) • 15.6 kB
JavaScript
'use strict';
const LngLat = require('./lng_lat'),
Point = require('point-geometry'),
Coordinate = require('./coordinate'),
util = require('../util/util'),
interp = require('../util/interpolate'),
TileCoord = require('../source/tile_coord'),
EXTENT = require('../data/extent'),
glmatrix = require('@mapbox/gl-matrix');
const vec4 = glmatrix.vec4,
mat4 = glmatrix.mat4,
mat2 = glmatrix.mat2;
/**
* A single transform, generally used for a single tile to be
* scaled, rotated, and zoomed.
* @private
*/
class Transform {
constructor(minZoom, maxZoom, renderWorldCopies) {
this.tileSize = 512; // constant
this._renderWorldCopies = renderWorldCopies === undefined ? true : renderWorldCopies;
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._fov = 0.6435011087932844;
this._pitch = 0;
this._unmodified = true;
}
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 renderWorldCopies() {
return this._renderWorldCopies;
}
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) {
const b = -util.wrap(bearing, -180, 180) * Math.PI / 180;
if (this.angle === b) return;
this._unmodified = false;
this.angle = b;
this._calcMatrices();
// 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) {
const p = util.clamp(pitch, 0, 60) / 180 * Math.PI;
if (this._pitch === p) return;
this._unmodified = false;
this._pitch = p;
this._calcMatrices();
}
get fov() {
return this._fov / Math.PI * 180;
}
set fov(fov) {
fov = Math.max(0.01, Math.min(60, fov));
if (this._fov === fov) return;
this._unmodified = false;
this._fov = fov / 180 * Math.PI;
this._calcMatrices();
}
get zoom() { return this._zoom; }
set zoom(zoom) {
const 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._constrain();
this._calcMatrices();
}
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._constrain();
this._calcMatrices();
}
/**
* Return a zoom level that will cover all tiles the transform
* @param {Object} options
* @param {number} options.tileSize
* @param {boolean} options.roundZoom
* @returns {number} zoom level
*/
coveringZoomLevel(options) {
return (options.roundZoom ? Math.round : Math.floor)(
this.zoom + this.scaleZoom(this.tileSize / options.tileSize)
);
}
/**
* Return any "wrapped" copies of a given tile coordinate that are visible
* in the current view.
*
* @private
*/
getVisibleWrappedCoordinates(tileCoord) {
const ul = this.pointCoordinate(new Point(0, 0), 0);
const ur = this.pointCoordinate(new Point(this.width, 0), 0);
const w0 = Math.floor(ul.column);
const w1 = Math.floor(ur.column);
const result = [tileCoord];
for (let w = w0; w <= w1; w++) {
if (w === 0) continue;
result.push(new TileCoord(tileCoord.z, tileCoord.x, tileCoord.y, w));
}
return result;
}
/**
* Return all coordinates that could cover this transform for a covering
* zoom level.
* @param {Object} options
* @param {number} options.tileSize
* @param {number} options.minzoom
* @param {number} options.maxzoom
* @param {boolean} options.roundZoom
* @param {boolean} options.reparseOverscaled
* @param {boolean} options.renderWorldCopies
* @returns {Array<Tile>} tiles
*/
coveringTiles(options) {
let z = this.coveringZoomLevel(options);
const actualZ = z;
if (z < options.minzoom) return [];
if (z > options.maxzoom) z = options.maxzoom;
const centerCoord = this.pointCoordinate(this.centerPoint, z);
const centerPoint = new Point(centerCoord.column - 0.5, centerCoord.row - 0.5);
const cornerCoords = [
this.pointCoordinate(new Point(0, 0), z),
this.pointCoordinate(new Point(this.width, 0), z),
this.pointCoordinate(new Point(this.width, this.height), z),
this.pointCoordinate(new Point(0, this.height), z)
];
return TileCoord.cover(z, cornerCoords, options.reparseOverscaled ? actualZ : z, this._renderWorldCopies)
.sort((a, b) => centerPoint.dist(a) - centerPoint.dist(b));
}
resize(width, height) {
this.width = width;
this.height = height;
this.pixelsToGLUnits = [2 / width, -2 / height];
this._constrain();
this._calcMatrices();
}
get unmodified() { return this._unmodified; }
zoomScale(zoom) { return Math.pow(2, zoom); }
scaleZoom(scale) { return Math.log(scale) / Math.LN2; }
project(lnglat) {
return new Point(
this.lngX(lnglat.lng),
this.latY(lnglat.lat));
}
unproject(point) {
return new LngLat(
this.xLng(point.x),
this.yLat(point.y));
}
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
* @returns {number} pixel coordinate
*/
lngX(lng) {
return (180 + lng) * this.worldSize / 360;
}
/**
* latitude to absolute y coord
* @param {number} lat
* @returns {number} pixel coordinate
*/
latY(lat) {
const y = 180 / Math.PI * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360));
return (180 - y) * this.worldSize / 360;
}
xLng(x) {
return x * 360 / this.worldSize - 180;
}
yLat(y) {
const y2 = 180 - y * 360 / this.worldSize;
return 360 / Math.PI * Math.atan(Math.exp(y2 * Math.PI / 180)) - 90;
}
setLocationAtPoint(lnglat, point) {
const translate = this.pointCoordinate(point)._sub(this.pointCoordinate(this.centerPoint));
this.center = this.coordinateLocation(this.locationCoordinate(lnglat)._sub(translate));
if (this._renderWorldCopies) {
this.center = this.center.wrap();
}
}
/**
* Given a location, return the screen point that corresponds to it
* @param {LngLat} lnglat location
* @returns {Point} screen point
*/
locationPoint(lnglat) {
return this.coordinatePoint(this.locationCoordinate(lnglat));
}
/**
* Given a point on screen, return its lnglat
* @param {Point} p screen point
* @returns {LngLat} lnglat location
*/
pointLocation(p) {
return this.coordinateLocation(this.pointCoordinate(p));
}
/**
* Given a geographical lnglat, return an unrounded
* coordinate that represents it at this transform's zoom level.
* @param {LngLat} lnglat
* @returns {Coordinate}
*/
locationCoordinate(lnglat) {
return new Coordinate(
this.lngX(lnglat.lng) / this.tileSize,
this.latY(lnglat.lat) / this.tileSize,
this.zoom).zoomTo(this.tileZoom);
}
/**
* Given a Coordinate, return its geographical position.
* @param {Coordinate} coord
* @returns {LngLat} lnglat
*/
coordinateLocation(coord) {
const zoomedCoord = coord.zoomTo(this.zoom);
return new LngLat(
this.xLng(zoomedCoord.column * this.tileSize),
this.yLat(zoomedCoord.row * this.tileSize));
}
pointCoordinate(p, zoom) {
if (zoom === undefined) zoom = this.tileZoom;
const targetZ = 0;
// 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
const coord0 = [p.x, p.y, 0, 1];
const coord1 = [p.x, p.y, 1, 1];
vec4.transformMat4(coord0, coord0, this.pixelMatrixInverse);
vec4.transformMat4(coord1, coord1, this.pixelMatrixInverse);
const w0 = coord0[3];
const w1 = coord1[3];
const x0 = coord0[0] / w0;
const x1 = coord1[0] / w1;
const y0 = coord0[1] / w0;
const y1 = coord1[1] / w1;
const z0 = coord0[2] / w0;
const z1 = coord1[2] / w1;
const t = z0 === z1 ? 0 : (targetZ - z0) / (z1 - z0);
return new Coordinate(
interp(x0, x1, t) / this.tileSize,
interp(y0, y1, t) / this.tileSize,
this.zoom)._zoomTo(zoom);
}
/**
* Given a coordinate, return the screen point that corresponds to it
* @param {Coordinate} coord
* @returns {Point} screen point
*/
coordinatePoint(coord) {
const zoomedCoord = coord.zoomTo(this.zoom);
const p = [zoomedCoord.column * this.tileSize, zoomedCoord.row * this.tileSize, 0, 1];
vec4.transformMat4(p, p, this.pixelMatrix);
return new Point(p[0] / p[3], p[1] / p[3]);
}
/**
* Calculate the posMatrix that, given a tile coordinate, would be used to display the tile on a map.
* @param {TileCoord} tileCoord
* @param {number} maxZoom maximum source zoom to account for overscaling
*/
calculatePosMatrix(tileCoord, maxZoom) {
// if z > maxzoom then the tile is actually a overscaled maxzoom tile,
// so calculate the matrix the maxzoom tile would use.
const coord = tileCoord.toCoordinate(maxZoom);
const scale = this.worldSize / this.zoomScale(coord.zoom);
const posMatrix = mat4.identity(new Float64Array(16));
mat4.translate(posMatrix, posMatrix, [coord.column * scale, coord.row * scale, 0]);
mat4.scale(posMatrix, posMatrix, [scale / EXTENT, scale / EXTENT, 1]);
mat4.multiply(posMatrix, this.projMatrix, posMatrix);
return new Float32Array(posMatrix);
}
_constrain() {
if (!this.center || !this.width || !this.height || this._constraining) return;
this._constraining = true;
let minY, maxY, minX, maxX, sy, sx, x2, y2;
const 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
const 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) {
const y = this.y,
h2 = size.y / 2;
if (y - h2 < minY) y2 = minY + h2;
if (y + h2 > maxY) y2 = maxY - h2;
}
if (this.lngRange) {
const 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;
}
_calcMatrices() {
if (!this.height) return;
this.cameraToCenterDistance = 0.5 / Math.tan(this._fov / 2) * this.height;
// Find the distance from the center point [width/2, height/2] to the
// center top point [width/2, 0] in Z units, using the law of sines.
// 1 Z unit is equivalent to 1 horizontal px at the center of the map
// (the distance between[width/2, height/2] and [width/2 + 1, height/2])
const halfFov = this._fov / 2;
const groundAngle = Math.PI / 2 + this._pitch;
const topHalfSurfaceDistance = Math.sin(halfFov) * this.cameraToCenterDistance / Math.sin(Math.PI - groundAngle - halfFov);
// Calculate z distance of the farthest fragment that should be rendered.
const furthestDistance = Math.cos(Math.PI / 2 - this._pitch) * topHalfSurfaceDistance + this.cameraToCenterDistance;
// Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance`
const farZ = furthestDistance * 1.01;
// matrix for conversion from location to GL coordinates (-1 .. 1)
let m = new Float64Array(16);
mat4.perspective(m, this._fov, this.width / this.height, 1, farZ);
mat4.scale(m, m, [1, -1, 1]);
mat4.translate(m, m, [0, 0, -this.cameraToCenterDistance]);
mat4.rotateX(m, m, this._pitch);
mat4.rotateZ(m, m, this.angle);
mat4.translate(m, m, [-this.x, -this.y, 0]);
// scale vertically to meters per pixel (inverse of ground resolution):
// worldSize / (circumferenceOfEarth * cos(lat * π / 180))
const verticalScale = this.worldSize / (2 * Math.PI * 6378137 * Math.abs(Math.cos(this.center.lat * (Math.PI / 180))));
mat4.scale(m, m, [1, 1, verticalScale, 1]);
this.projMatrix = m;
// matrix for conversion from location to screen coordinates
m = mat4.create();
mat4.scale(m, m, [this.width / 2, -this.height / 2, 1]);
mat4.translate(m, m, [1, -1, 0]);
this.pixelMatrix = mat4.multiply(new Float64Array(16), m, this.projMatrix);
// inverse matrix for conversion from screen coordinaes to location
m = mat4.invert(new Float64Array(16), this.pixelMatrix);
if (!m) throw new Error("failed to invert matrix");
this.pixelMatrixInverse = m;
}
}
module.exports = Transform;