UNPKG

tangram

Version:
529 lines (445 loc) 20 kB
/*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 ); } }