tangram
Version:
WebGL Maps for Vector Tiles
307 lines (259 loc) • 13.2 kB
JavaScript
import DataSource, {NetworkTileSource} from './data_source';
import {TileID} from '../tile/tile_id';
import Geo from '../utils/geo';
import Texture from '../gl/texture';
import Utils from '../utils/utils';
import hashString from '../utils/hash';
import log from '../utils/log';
export class RasterTileSource extends NetworkTileSource {
constructor (source, sources) {
super(source, sources);
if (this.rasters.indexOf(this.name) === -1) {
this.rasters.unshift(this.name); // add this raster as the first
}
this.filtering = source.filtering; // optional texture filtering (nearest, linear, mipmap)
// save texture objects by tile key, so URL remains stable if tile is built multiple times,
// e.g. avoid re-loading the same tile texture under a different subdomain when using tile hosts
this.textures = {};
}
async load (tile) {
tile.source_data = {};
tile.source_data.layers = {};
tile.pad_scale = this.pad_scale;
tile.rasters = [...this.rasters]; // copy list of rasters to load for tile
// Generate a single quad that fills the entire tile
let scale = Geo.tile_scale;
tile.source_data.layers = {
_default: {
type: 'FeatureCollection',
features: [{
geometry: {
type: 'Polygon',
coordinates: [[
[0, 0], [scale, 0],
[scale, -scale], [0, -scale], [0, 0]
]]
},
properties: {}
}]
}
};
tile.default_winding = 'CW';
return tile;
}
// Return texture info for a raster tile
async tileTexture (tile) {
let coords = this.adjustRasterTileZoom(tile);
let key = coords.key;
// texture definitions are cached to avoid loading the same raster tile multiple times,
// e.g. due to slightly different URLs when subdomain pattern is used (a.tile.com vs. b.tile.com)
if (!this.textures[key]) {
let url = this.formatURL(this.url, { coords });
this.textures[key] = {
name: url,
url,
filtering: this.filtering,
coords
};
}
return this.textures[key];
}
// If the raster is attached to another source, we need to compare their levels of zoom detail
// to see if any adjustments are needed. Both the `tile_size` and `zoom_offset` data source params
// cause the zoom level to be downsampled relative to the "base" zoom level of the map view.
// The attaching source has already applied its own zoom downsampling. If this source has a lower
// level of detail, we apply the remaining differential here.
adjustRasterTileZoom (tile) {
let coords = tile.coords;
const tile_source = this.sources[tile.source];
if (tile_source !== this) { // no-op if the raster source isn't being rendered as an attachment
let zdiff = this.zoom_bias - tile_source.zoom_bias; // difference in zoom detail between the sources
if (zdiff > 0) { // raster source is less detailed
// do extra zoom adjustment and apply this raster source's max zoom
coords = TileID.normalizedCoord(tile.coords, {
zoom_bias: zdiff,
zooms: this.zooms
});
}
else {
// raster source supports higher detail, but was downsampled to match (the downsampling already
// happened upstream, when the attaching source calculated its own tile coordinate)
if (zdiff < 0) {
log({ level: 'warn', once: true},
`Raster source '${this.name}' supports higher zoom detail than source '${tile_source.name}' ` +
`it's attached to. Downsampling this source ${-zdiff} extra zoom levels to match.`
);
}
// no extra zoom adjustment needed, but still need to apply this raster source's max zoom
coords = TileID.coordForTileZooms(coords, this.zooms);
}
}
return coords;
}
}
// Data source for loading standalone, geo-referenced raster images
// The `bounds` parameter is used to position the raster image on the map
// Currently, only axis-aligned, rectangular North-up images are supported
// TODO: add support for arbitrarily rotated images and quadrangle control points
export class RasterSource extends RasterTileSource {
constructor (source, sources) {
super(source, sources);
this.load_image = {}; // resolves to image, cached for life of data source
// alpha factor applied at time raster images are loaded and tiled (*not* at shader render-time)
this.alpha = (source.alpha != null) ? Math.max(Math.min(source.alpha, 1), 0) : null;
// non-full-alpha pixels should be discarded (for rendering rasters w/opaque blend)
this.mask_alpha = true;
// don't retain tiles for this source from nearby zooms (to improve memory usage)
this.preserve_tiles_within_zoom = 0;
// optionally set a max pixel density used for generated raster tiles (to improve memory usage)
this.max_display_density = source.max_display_density;
// Optionally composite multiple images into one raster layer
if (Array.isArray(source.composite)) {
// TODO: calculate enclosing bounding box to speed tile intersection checks
this.images = source.composite.map(s => {
return {
url: s.url,
bounds: this.parseBounds(s),
alpha: (s.alpha != null) ? Math.max(Math.min(s.alpha, 1), 0) : null
};
});
}
// Single image raster layer
else {
this.images = [{
url: this.url,
bounds: this.bounds,
alpha: this.alpha
}];
}
}
// Render the sub-rectangle of the source raster image for the given tile, to a texture.
// Clipping the source image to individual raster tiles naturally partitions images
// (which may be large or only have a small portion in current view), and maintains
// consistency with the raster tile pipeline allowing for sampling within the fragment shader,
// and clipping the raster against vector source data.
async tileTexture (tile, { blend, generation }) {
let coords = this.adjustRasterTileZoom(tile);
const use_alpha = (blend !== 'opaque'); // ignore source alpha multiplier with opaque blending
const name = `raster-${this.name}-${coords.key}-${use_alpha ? 'alpha' : 'opaque'}-${generation}`; // unique texture name
// only render each raster tile once (per scene generation)
if (Texture.textures[name]) {
return {
name,
coords,
// tell style to skip re-creating this texture
// we have an explicit flag for this because element-based (e.g. canvas) textures
// are usually considered dynamic and always re-created when a new tile needs them
// (because the user could have updated the canvas pixel contents outside of Tangram)
skip_create: true
};
}
// Display density, with extra 2x for better intra-zoom scaling, because raster tiles
// can be scaled up to 100% before next zoom level is loaded
let dpr = Utils.device_pixel_ratio;
if (this.max_display_density) {
dpr = Math.min(dpr, this.max_display_density); // optionally cap pixel density
}
dpr *= 2;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = this.tile_size * dpr; // adjusted for display density
canvas.height = this.tile_size * dpr;
// Applies nearest neighbor filtering to the canvas image rendering when data source requests it
// NB: does not work on IE11 (image will be blurry when scaled)
ctx.imageSmoothingEnabled = (this.filtering !== 'nearest');
// Draw one or more images
const images = this.images.filter(r => this.checkBounds(tile.coords, r.bounds));
await Promise.all(images.map(i => {
// TODO: log warning if alpha specified but will be ignored (in opaque mode)?
const alpha = (use_alpha ? (i.alpha != null ? i.alpha : this.alpha) : 1);
return this.drawImage(i.url, i.bounds, alpha, tile, dpr, ctx);
}));
// texture config
return {
name,
element: canvas,
filtering: this.filtering,
coords
};
}
// Draw a single image to the tile canvas based on on its bounds
async drawImage (url, bounds, alpha, tile, dpr, ctx) {
// Get source raster image
const key = hashString(url); // use hash of URL for shorter keys
this.load_image[key] = this.load_image[key] || this.loadImage(url);
const image = await this.load_image[key];
// Meters per pixel for this zoom, adjusted for display density and source tile size (e.g. 512px tiles)
const mpp = Geo.metersPerPixel(tile.coords.z) / dpr / (this.tile_size / Geo.tile_size);
// Raster origin relative to tile origin (get delta in meters, then convert to pixels)
const dx = (bounds.meters.min[0] - tile.min.x) / mpp;
const dy = -(bounds.meters.min[1] - tile.min.y) / mpp;
// Raster size in pixels for current zoom
const sx = (bounds.meters.max[0] - bounds.meters.min[0]) / mpp;
const sy = -(bounds.meters.max[1] - bounds.meters.min[1]) / mpp;
// Draw the raster, clipped to the current tile
// NB: this is drawing the *whole* raster, no matter how large, and relying on the native Canvas clipping.
// May want to benchmark with a pre-clipped draw area, though the native implementation is likely fast,
// and has to apply its own clipping check anyway.
ctx.globalAlpha = (alpha != null) ? alpha : 1;
ctx.drawImage(image, dx, dy, sx, sy);
}
// Load source raster image
loadImage (url) {
return new Promise(resolve => {
let image = new Image();
image.onload = () => resolve(image);
image.onerror = e => {
// Warn and resolve on error
log('warn', `Raster source '${this.name}': failed to load url: '${url}'`, e);
resolve(null);
};
// Safari has a bug loading data-URL images with CORS enabled, so it must be disabled in that case
// https://bugs.webkit.org/show_bug.cgi?id=123978
if (!(Utils.isSafari() && url.slice(0, 5) === 'data:')) {
image.crossOrigin = 'anonymous';
}
image.src = url;
});
}
// Checks if tile interects any rasters in this source
includesTile (coords, style_z) {
// Checks zoom range and dependent rasters
if (!DataSource.prototype.includesTile.call(this, coords, style_z)) {
return false;
}
return this.images.some(r => this.checkBounds(coords, r.bounds)); // check if any images intersect
}
validate (source) {
const is_composite = Array.isArray(source.composite);
let url_msg = 'Raster data source must provide a string `url` parameter, or an array of `composite` raster ';
url_msg += 'image objects that each have a `url` parameter';
let bounds_msg = 'Raster data source must provide a `bounds` parameter, or an array of `composite` raster ';
bounds_msg += 'image objects that each have a `bounds` parameter';
let mutex_msg = 'Raster data source must have *either* a single image specified as `url` and `bounds `';
mutex_msg += 'parameters, or an array of `composite` raster image objects, each with `url` and `bounds`.';
if (is_composite) {
if (source.composite.some(s => typeof s.url !== 'string')) {
throw Error(url_msg);
}
if (source.composite.some(s => !(Array.isArray(s.bounds) && s.bounds.length === 4))) {
throw Error(bounds_msg);
}
if (source.url != null || source.bounds != null) {
throw Error(mutex_msg);
}
}
else {
if (typeof source.url !== 'string') {
throw Error(url_msg);
}
if (!(Array.isArray(source.bounds) && source.bounds.length === 4)) {
throw Error(bounds_msg);
}
}
}
}
// Check for URL tile pattern, if not found, treat as geo-referenced raster layer
DataSource.register('Raster', source => {
return RasterTileSource.urlHasTilePattern(source.url) ? RasterTileSource : RasterSource;
});