tangram
Version:
WebGL Maps for Vector Tiles
350 lines (291 loc) • 11.8 kB
JavaScript
import Geo from '../utils/geo';
import {TileID} from '../tile/tile_id';
import Camera from './camera';
import Utils from '../utils/utils';
import subscribeMixin from '../utils/subscribe';
import log from '../utils/log';
export const VIEW_PAN_SNAP_TIME = 0.5;
export default class View {
constructor (scene, options) {
subscribeMixin(this);
this.scene = scene;
this.createMatrices();
this.zoom = null;
this.center = null;
this.bounds = null;
this.meters_per_pixel = null;
this.panning = false;
this.panning_stop_at = 0;
this.pan_snap_timer = 0;
this.zoom_direction = 0;
this.user_input_at = 0;
this.user_input_timeout = 50;
this.user_input_active = false;
// Size of viewport in CSS pixels, device pixels, and mercator meters
this.size = {
css: {},
device: {},
meters: {}
};
this.aspect = null;
this.buffer = 0;
this.continuous_zoom = (typeof options.continuousZoom === 'boolean') ? options.continuousZoom : true;
this.wrap = (options.wrapView === false) ? false : true;
this.preserve_tiles_within_zoom = 1;
this.reset();
}
// Reset state before scene config is updated
reset () {
this.createCamera();
}
// Create camera
createCamera () {
let active_camera = this.getActiveCamera();
if (active_camera) {
this.camera = Camera.create(active_camera, this, this.scene.config.cameras[active_camera]);
this.camera.updateView();
}
}
// Get active camera - for public API
getActiveCamera () {
if (this.scene.config && this.scene.config.cameras) {
for (let name in this.scene.config.cameras) {
if (this.scene.config.cameras[name].active) {
return name;
}
}
// If no camera set as active, use first one
let keys = Object.keys(this.scene.config.cameras);
return keys.length && keys[0];
}
}
// Set active camera and recompile - for public API
setActiveCamera (name) {
let prev = this.getActiveCamera();
if (prev === name) {
return name;
}
if (this.scene.config.cameras[name]) {
this.scene.config.cameras[name].active = true;
// Clear previously active camera
if (prev && this.scene.config.cameras[prev]) {
delete this.scene.config.cameras[prev].active;
}
}
this.scene.updateConfig({ rebuild: false, normalize: false });
return this.getActiveCamera();
}
// Update method called once per frame
update () {
if (this.camera != null && this.ready()) {
this.camera.update();
}
this.pan_snap_timer = ((+new Date()) - this.panning_stop_at) / 1000;
this.user_input_active = ((+new Date() - this.user_input_at) < this.user_input_timeout);
}
// Set logical pixel size of viewport
setViewportSize (width, height) {
this.size.css = { width, height };
this.size.device = {
width: Math.round(this.size.css.width * Utils.device_pixel_ratio),
height: Math.round(this.size.css.height * Utils.device_pixel_ratio)
};
this.aspect = this.size.css.width / this.size.css.height;
this.updateBounds();
}
// Set the map view, can be passed an object with lat/lng and/or zoom
setView ({ lng, lat, zoom } = {}) {
var changed = false;
// Set center
if (typeof lng === 'number' && typeof lat === 'number') {
if (!this.center || lng !== this.center.lng || lat !== this.center.lat) {
changed = true;
this.center = { lng, lat };
}
}
// Set zoom
if (typeof zoom === 'number' && zoom !== this.zoom) {
changed = true;
this.setZoom(zoom);
}
if (changed) {
this.updateBounds();
}
return changed;
}
setZoom (zoom) {
let last_tile_zoom = this.tile_zoom;
let tile_zoom = this.baseZoom(zoom);
if (!this.continuous_zoom) {
zoom = tile_zoom;
}
if (tile_zoom !== last_tile_zoom) {
this.zoom_direction = tile_zoom > last_tile_zoom ? 1 : -1;
}
this.zoom = zoom;
this.tile_zoom = tile_zoom;
this.updateBounds();
this.scene.requestRedraw();
}
// Choose the base zoom level to use for a given fractional zoom
baseZoom (zoom) {
return Math.floor(zoom);
}
setPanning (panning) {
this.panning = panning;
if (!this.panning) {
this.panning_stop_at = (+new Date());
}
}
markUserInput () {
this.user_input_at = (+new Date());
}
ready () {
// TODO: better concept of "readiness" state?
if (typeof this.size.css.width !== 'number' ||
typeof this.size.css.height !== 'number' ||
this.center == null ||
typeof this.zoom !== 'number') {
return false;
}
return true;
}
// Calculate viewport bounds based on current center and zoom
updateBounds () {
if (!this.ready()) {
return;
}
this.meters_per_pixel = Geo.metersPerPixel(this.zoom);
// Size of the half-viewport in meters at current zoom
this.size.meters = {
x: this.size.css.width * this.meters_per_pixel,
y: this.size.css.height * this.meters_per_pixel
};
// Center of viewport in meters, and tile
const m = Geo.latLngToMeters([this.center.lng, this.center.lat]);
this.center.meters = { x: m[0], y: m[1] };
this.center.tile = Geo.tileForMeters([this.center.meters.x, this.center.meters.y], this.tile_zoom);
// Bounds in meters
this.bounds = {
sw: {
x: this.center.meters.x - this.size.meters.x / 2,
y: this.center.meters.y - this.size.meters.y / 2
},
ne: {
x: this.center.meters.x + this.size.meters.x / 2,
y: this.center.meters.y + this.size.meters.y / 2
}
};
this.scene.tile_manager.updateTilesForView();
this.trigger('move');
this.scene.requestRedraw(); // TODO automate via move event?
}
findVisibleTileCoordinates () {
if (!this.bounds) {
return [];
}
let z = this.tile_zoom;
let sw = Geo.tileForMeters([this.bounds.sw.x, this.bounds.sw.y], z);
let ne = Geo.tileForMeters([this.bounds.ne.x, this.bounds.ne.y], z);
let range = [
sw.x - this.buffer, ne.x + this.buffer, // x
ne.y - this.buffer, sw.y + this.buffer // y
];
if (this.wrap === false) { // prevent tiles from wrapping across antimeridian
let tmax = (1 << z) - 1; // max xy tile number for this zoom
range = range.map(v => Math.min(Math.max(0, v), tmax));
}
let coords = [];
for (let x = range[0]; x <= range[1]; x++) {
for (let y = range[2]; y <= range[3]; y++) {
coords.push(TileID.coord({ x, y, z }));
}
}
return coords;
}
// Remove tiles too far outside of view
pruneTilesForView () {
// TODO: will this function ever be called when view isn't ready?
if (!this.ready()) {
return;
}
this.scene.tile_manager.removeTiles(tile => {
// Ignore visible tiles
if (tile.visible || tile.isProxy()) {
return false;
}
// Remove tiles outside given zoom that are still loading
if (tile.loading && tile.style_z !== this.tile_zoom) {
return true;
}
// Discard if too far from current zoom
const zdiff = Math.abs(tile.style_z - this.tile_zoom);
const preserve_tiles_within_zoom = (tile.preserve_tiles_within_zoom != null ?
tile.preserve_tiles_within_zoom : this.preserve_tiles_within_zoom); // optionally tile source specific
if (zdiff > preserve_tiles_within_zoom) {
return true;
}
// Discard tiles outside an area surrounding the viewport, handling tiles at different zooms
// Get min and max tiles for the viewport, at the scale of the tile currently being evaluated
const view_buffer = this.meters_per_pixel * Geo.tile_size; // buffer area to keep tiles surrounding viewport
const view_tile_min = TileID.coordAtZoom(
Geo.tileForMeters(
[
this.center.meters.x - this.size.meters.x/2 - view_buffer,
this.center.meters.y + this.size.meters.y/2 + view_buffer
],
this.tile_zoom),
tile.coords.z);
const view_tile_max = TileID.coordAtZoom(
Geo.tileForMeters(
[
this.center.meters.x + this.size.meters.x/2 + view_buffer,
this.center.meters.y - this.size.meters.y/2 - view_buffer
],
this.tile_zoom),
tile.coords.z);
if (tile.coords.x < view_tile_min.x || tile.coords.x > view_tile_max.x ||
tile.coords.y < view_tile_min.y || tile.coords.y > view_tile_max.y) {
log('trace', `View: remove tile ${tile.key} (as ${tile.coords.key}) ` +
`for being too far out of visible area (${view_tile_min.key}, ${view_tile_max.key})`);
return true;
}
return false;
});
}
// Allocate model-view matrices
// 64-bit versions are for CPU calcuations
// 32-bit versions are downsampled and sent to GPU
createMatrices () {
this.matrices = {};
this.matrices.model = new Float64Array(16);
this.matrices.model32 = new Float32Array(16);
this.matrices.model_view = new Float64Array(16);
this.matrices.model_view32 = new Float32Array(16);
this.matrices.normal = new Float64Array(9);
this.matrices.normal32 = new Float32Array(9);
this.matrices.inverse_normal32 = new Float32Array(9);
}
// Calculate and set model/view and normal matrices for a tile
setupTile (tile, program) {
// Tile-specific state
// TODO: calc these once per tile (currently being needlessly re-calculated per-tile-per-style)
tile.setupProgram(this.matrices, program);
// Model-view and normal matrices
this.camera.setupMatrices(this.matrices, program);
}
// Set general uniforms that must be updated once per program
setupProgram (program) {
program.uniform('2fv', 'u_resolution', [this.size.device.width, this.size.device.height]);
program.uniform('3fv', 'u_map_position', [this.center.meters.x, this.center.meters.y, this.zoom]);
program.uniform('1f', 'u_meters_per_pixel', this.meters_per_pixel);
program.uniform('1f', 'u_device_pixel_ratio', Utils.device_pixel_ratio);
program.uniform('1f', 'u_view_pan_snap_timer', this.pan_snap_timer);
program.uniform('1i', 'u_view_panning', this.panning);
this.camera.setupProgram(program);
}
// View requires some animation, such as after panning stops
isAnimating () {
return (this.pan_snap_timer <= VIEW_PAN_SNAP_TIME);
}
}