tangram
Version:
WebGL Maps for Vector Tiles
529 lines (445 loc) • 20 kB
JavaScript
/*jshint worker: true */
import Geo from '../utils/geo';
import {MethodNotImplemented} from '../utils/errors';
import Utils from '../utils/utils';
import sliceObject from '../utils/slice';
import * as URLs from '../utils/urls';
import log from '../utils/log';
export default class DataSource {
constructor (config, sources) {
this.validate(config);
this.config = config; // save original config
this.sources = sources; // full set of data sources TODO: centralize these like textures?
this.id = config.id;
this.name = config.name;
this.pad_scale = config.pad_scale || 0.00001; // scale tile up by small factor to cover seams
this.default_winding = null; // winding order will adapt to data source
this.rasters = []; // attached raster tile sources
if (Array.isArray(config.rasters)) { // copy unique set of raster sources
config.rasters.forEach(r => {
if (this.rasters.indexOf(r) === -1) {
this.rasters.push(r);
}
});
}
// Optional function to preprocess source data
this.preprocess = config.preprocess;
if (typeof this.preprocess === 'function') {
this.preprocess.bind(this);
}
// Optional function to transform source data
this.transform = config.transform;
if (typeof this.transform === 'function') {
this.transform.bind(this);
}
// Optional additional data to pass to the transform function
this.extra_data = config.extra_data;
// Optional additional scripts made available to the transform function
// NOTE: these are loaded alongside the library when the workers are instantiated
this.scripts = config.scripts;
// Configure zoom ranges at which new data will be loaded
this.setZooms(config);
// set a custom extra overzoom adjustment factor to load consistently lower zoom levels
// than the current map zoom level – eg a zoom_offset of 1 would load z3 data at z4
this.zoom_offset = (config.zoom_offset != null) ? config.zoom_offset : 0;
if (this.zoom_offset < 0) {
let msg = `Data source '${this.name}' zoom_offset must not be negative – setting to 0.`;
log({ level: 'warn', once: true }, msg);
this.zoom_offset = 0;
}
this.setTileSize(config.tile_size);
// no tiles will be requested or displayed outside of these min/max values
this.min_display_zoom = Math.max(config.min_display_zoom || 0, this.zooms[0]);
this.max_display_zoom = (config.max_display_zoom != null) ? config.max_display_zoom : null;
}
// Register a new data source type name, providing a function that returns the class name
// to instantiate based on the source definition in the scene
static register (type_name, type_func) {
if (!type_name || !type_func) {
return;
}
DataSource.types[type_name] = type_func;
}
// Create a data source, factory-style
static create (source, sources) {
// Find the class to instantiate based on the source definition
if (typeof DataSource.types[source.type] === 'function') {
const source_class = DataSource.types[source.type](source);
if (source_class) {
return new source_class(source, sources);
}
}
}
// Check if a data source definition changed in a way that could affect which tiles are in view
static tileLayoutChanged (source, prev_source) {
if (!source || !prev_source) {
return true;
}
// subset of parameters that affect tile layout
const rebuild_params = [
'max_zoom',
'zooms',
'min_display_zoom',
'max_display_zoom',
'bounds',
'tile_size',
'zoom_offset'
];
const cur = sliceObject(source.config, rebuild_params);
const prev = sliceObject(prev_source.config, rebuild_params);
return JSON.stringify(cur) !== JSON.stringify(prev);
}
// Mercator projection
static projectData (source) {
var timer = +new Date();
for (var t in source.layers) {
var num_features = source.layers[t].features.length;
for (var f=0; f < num_features; f++) {
var feature = source.layers[t].features[f];
Geo.transformGeometry(feature.geometry, this.projectCoord);
}
}
if (source.debug !== undefined) {
source.debug.projection = +new Date() - timer;
}
}
static projectCoord (coord) {
Geo.latLngToMeters(coord);
}
/**
Re-scale geometries within each source to internal tile units
*/
static scaleData (source, { coords: { z }, min }) {
let units_per_meter = Geo.unitsPerMeter(z);
for (var t in source.layers) {
var num_features = source.layers[t].features.length;
for (var f=0; f < num_features; f++) {
var feature = source.layers[t].features[f];
Geo.transformGeometry(feature.geometry, coord => {
coord[0] = (coord[0] - min.x) * units_per_meter;
coord[1] = (coord[1] - min.y) * units_per_meter * -1; // flip coords positive
});
}
}
}
load (dest) {
dest.source_data = {};
dest.source_data.layers = {};
dest.pad_scale = this.pad_scale;
dest.rasters = [...this.rasters]; // copy list of rasters to load for tile
return this._load(dest).then((dest) => {
// Post-processing
for (let layer in dest.source_data.layers) {
let data = dest.source_data.layers[layer];
if (data && data.features) {
data.features.forEach(feature => {
Geo.transformGeometry(feature.geometry, coord => {
// Flip Y coords
coord[1] = -coord[1];
// Slightly scale up tile to cover seams
if (this.pad_scale) {
coord[0] = Math.round(coord[0] * (1 + this.pad_scale) - (Geo.tile_scale * this.pad_scale/2));
coord[1] = Math.round(coord[1] * (1 + this.pad_scale) - (Geo.tile_scale * this.pad_scale/2));
}
});
// Use first encountered polygon winding order as default for data source
this.updateDefaultWinding(feature.geometry);
});
}
}
dest.default_winding = this.default_winding || 'CCW';
return dest;
});
}
// Sub-classes must implement
_load (/*dest*/) {
throw new MethodNotImplemented('_load');
}
// Copy source data from another tile (so we can reuse source data for overzoomed tiles)
copyTileData (source, dest) {
log('trace', `Copy tile data from ${source.key} to ${dest.key}`);
dest.source_data = { layers: source.source_data.layers };
dest.rasters = [...source.rasters];
dest.pad_scale = source.pad_scale;
dest.default_winding = source.default_winding;
return dest;
}
// Configure zoom ranges at which new data will be loaded
// e.g. can be used to skip fetching data for some zooms, reusing data from next lowest available zoom instead
setZooms ({ max_zoom, zooms }) {
// overzoom will apply for zooms higher than this
this.max_zoom = (max_zoom != null) ? max_zoom : Geo.default_source_max_zoom;
if (Array.isArray(zooms)) {
this.zooms = zooms; // TODO: support range parsing, e.g. [0-4, 6-7, 12]?
this.max_zoom = this.zooms[this.zooms.length-1]; // overrides `max_zoom` when both are present
}
else {
this.zooms = [];
for (let i = 0; i <= this.max_zoom; i++) {
this.zooms[i] = i;
}
}
}
// Set the internal tile size in pixels, e.g. '256px' (default), '512px', etc.
// Must be a power of 2, and greater than or equal to 256
setTileSize (tile_size) {
this.tile_size = tile_size || 256;
if (typeof this.tile_size !== 'number' || this.tile_size < 256 || !Utils.isPowerOf2(this.tile_size)) {
log({ level: 'warn', once: true },
`Data source '${this.name}': 'tile_size' parameter must be a number that is a power of 2 greater than or equal to 256, but was '${tile_size}'`);
this.tile_size = 256;
}
// # of zoom levels bigger than 256px tiles - 8 in place of log2(256)
// Many Tangram functions assume 256px tiles, this factor adjusts for the
// case of bigger tile sizes - eg 512px tiles are 1 zoom level bigger,
// 1024px tiles are 2 levels bigger
this.zoom_bias = Math.log2(this.tile_size) - 8 + this.zoom_offset;
}
// Infer winding for data source from first ring of provided geometry
updateDefaultWinding (geom) {
if (this.default_winding == null) {
if (geom.type === 'Polygon') {
this.default_winding = Geo.ringWinding(geom.coordinates[0]);
}
else if (geom.type === 'MultiPolygon') {
this.default_winding = Geo.ringWinding(geom.coordinates[0][0]);
}
}
return this.default_winding;
}
// All data sources support a min zoom, tiled sources can subclass for more specific limits (e.g. bounding box)
includesTile (coords, style_z) {
// Limit by this data source
if (coords.z < this.min_display_zoom || (this.max_display_zoom != null && style_z > this.max_display_zoom)) {
return false;
}
// Limit by any dependent raster sources
for (let r=0; r < this.rasters.length; r++) {
const source_name = this.rasters[r];
if (this.sources[source_name] &&
this.sources[source_name] !== this &&
!this.sources[source_name].includesTile(coords, coords.z)) {
return false;
}
}
return true;
}
validate (/*source*/) {
}
}
DataSource.types = {}; // set of supported data source classes, referenced by type name
/*** Generic network loading source - abstract class ***/
let network_request_id = 0; // used to namespace URL requests
export class NetworkSource extends DataSource {
constructor (source, sources) {
super(source, sources);
this.response_type = ''; // use to set explicit XHR type
// Add extra URL params, and warn on duplicates
let [url, dupes] = URLs.addParamsToURL(source.url, source.url_params);
this.url = url;
dupes.forEach(([param, value]) => {
log({ level: 'warn', once: true },
`Data source '${this.name}': parameter '${param}' already present in URL '${source.url}', ` +
`skipping value '${param}=${value}' specified in 'url_params'`);
});
// Optional HTTP request headers to send
if (source.request_headers && typeof source.request_headers === 'object') {
this.request_headers = source.request_headers;
}
}
_load (dest) {
let url = this.formatURL(this.url, dest);
let source_data = dest.source_data;
source_data.url = url;
dest.debug = dest.debug || {};
dest.debug.network = +new Date();
return new Promise(resolve => {
let request_id = (network_request_id++) + '-' + url;
let promise = Utils.io(url, 60 * 1000, this.response_type, 'GET', this.request_headers, request_id);
source_data.request_id = request_id;
source_data.error = null;
promise.then(({ body }) => {
dest.debug.response_size = body && (body.length || body.byteLength);
dest.debug.network = +new Date() - dest.debug.network;
dest.debug.parsing = +new Date();
// Apply optional data transform on raw network response
if (body != null && typeof this.preprocess === 'function') {
body = this.preprocess(body);
}
// Return data immediately, or after user-returned promise resolves
body = (body instanceof Promise) ? body : Promise.resolve(body);
body.then(body => {
if (body != null) {
this.parseSourceData(dest, source_data, body);
}
else {
source_data.layers = {}; // for cases where server returned no content (e.g. 204 response)
}
dest.debug.parsing = +new Date() - dest.debug.parsing;
resolve(dest);
});
}).catch((error) => {
source_data.error = error.stack;
resolve(dest); // resolve request but pass along error
});
});
}
validate (source) {
if (typeof source.url !== 'string') {
throw Error('Network data source must provide a string `url` property');
}
}
// Sub-classes must implement:
formatURL (/*url_template, dest*/) {
throw new MethodNotImplemented('formatURL');
}
parseSourceData (/*dest, source, reponse*/) {
throw new MethodNotImplemented('parseSourceData');
}
}
/*** Generic network tile loading - abstract class ***/
export class NetworkTileSource extends NetworkSource {
constructor (source, sources) {
super(source, sources);
this.tiled = true;
this.bounds = this.parseBounds(source);
// indicates if source should build geometry tiles, enabled for sources referenced in the scene's layers,
// and left disabled for sources that are never referenced, or only used as raster textures
this.builds_geometry_tiles = false;
this.tms = (source.tms === true); // optionally flip tile coords for TMS
// optional list of subdomains to round-robin through
if (this.url && this.url.search('{s}') > -1) {
if (Array.isArray(source.url_subdomains) && source.url_subdomains.length > 0) {
this.url_subdomains = source.url_subdomains;
this.next_url_subdomain = 0;
}
else {
log({ level: 'warn', once: true },
`Data source '${this.name}': source URL includes '{s}' subdomain marker ('${this.url}'), but no subdomains ` +
'were specified in \'url_subdomains\' parameter');
}
}
// optional list of pixel density scale modifiers
if (this.url && this.url.search('{r}') > -1) {
if (Array.isArray(source.url_density_scales) && source.url_density_scales.length > 0) {
this.url_density_scales = source.url_density_scales;
}
else {
this.url_density_scales = [1, 2]; // default to supporting 1x and 2x display densities
}
}
}
// Get bounds from source config parameters
parseBounds (source) {
if (Array.isArray(source.bounds) && source.bounds.length === 4) {
const [w, s, e, n] = source.bounds;
return {
latlng: [...source.bounds],
meters: {
min: Geo.latLngToMeters([w, n]),
max: Geo.latLngToMeters([e, s]),
},
tiles: { // max tile bounds per zoom (lazily evaluated)
min: {},
max: {}
}
};
}
}
// Returns false if tile is outside data source's bounds, true if within
checkBounds (coords, bounds) {
// Check tile bounds
if (bounds) {
// get tile and bounds coords at current zoom, wrapping to keep x coords in positive range
coords = Geo.wrapTile(coords);
let min = bounds.tiles.min[coords.z];
if (!min) {
min = bounds.tiles.min[coords.z] = Geo.tileForMeters(bounds.meters.min, coords.z);
}
let max = bounds.tiles.max[coords.z];
if (!max) {
max = bounds.tiles.max[coords.z] = Geo.tileForMeters(bounds.meters.max, coords.z);
}
// check latitude
if (coords.y < min.y || coords.y > max.y) {
return false;
}
// longitude bounds are between meridians
if (min.x <= max.x) {
if (coords.x < min.x || coords.x > max.x) {
return false;
}
}
// longitude bounds cross the antimeridian
else if (coords.x > max.x && coords.x < min.x) {
return false;
}
}
return true;
}
includesTile (coords, style_z) {
if (!super.includesTile(coords, style_z)) {
return false;
}
// Check tile bounds
if (!this.checkBounds(coords, this.bounds)) {
return false;
}
return true;
}
formatURL (url_template, tile) {
let coords = Geo.wrapTile(tile.coords, { x: true });
if (this.tms) {
coords.y = Math.pow(2, coords.z) - 1 - coords.y; // optionally flip tile coords for TMS
}
// tile URL template replacement
let url = url_template
.replace('{x}', coords.x)
.replace('{y}', coords.y)
.replace('{z}', coords.z)
.replace('{r}', this.getDensityModifier()) // modify URL by display density (e.g. @2x)
.replace('{q}', this.toQuadKey(coords)); // quadkey for tile coordinates
if (this.url_subdomains != null) {
url = url.replace('{s}', this.url_subdomains[this.next_url_subdomain]);
this.next_url_subdomain = (this.next_url_subdomain + 1) % this.url_subdomains.length;
}
return url;
}
// Find the right tile URL modifier based on the display density, e.g. add `@2x` for sources supporting 2x tiles.
// Source `url_density_scales` param can specify an array of densities supported by the source,
// each entry serves as a threshold based on the current display density.
getDensityModifier () {
if (this.url_density_scales) {
// find the highest matching density
const dpr = Utils.device_pixel_ratio;
let scale = this.url_density_scales
.filter(s => dpr >= s)
.reverse()[0];
// default to first scale if none matched
scale = (scale != null ? scale : this.url_density_scales[0]);
// scales higher than 1x use the `@` modifier (e.g. `@2x`)
if (scale > 1) {
return `@${scale}x`;
}
}
return ''; // for 1x (or less) displays, no URL modifier is used (following @2x URL convention)
}
toQuadKey ({ x, y, z }) {
let quadkey = '';
for (let i = z; i > 0; i--) {
let b = 0;
let mask = 1 << (i - 1);
if ((x & mask) !== 0) b++;
if ((y & mask) !== 0) b += 2;
quadkey += b.toString();
}
return quadkey;
}
// Checks for the x/y/z tile pattern in URL template
static urlHasTilePattern (url) {
return url && (
(url.search('{x}') > -1 && url.search('{y}') > -1 && url.search('{z}') > -1) ||
url.search('{q}') > -1
);
}
}