modules/renderer/background_source.js (458 lines of code) (raw):
import { geoArea as d3_geoArea, geoMercatorRaw as d3_geoMercatorRaw } from 'd3-geo';
import { json as d3_json } from 'd3-fetch';
import { utilAesDecrypt, utilQsString, utilStringQs } from '@id-sdk/util';
import { Extent, geoSphericalDistance } from '@id-sdk/math';
import { t, localizer } from '../core/localizer';
var isRetina = window.devicePixelRatio && window.devicePixelRatio >= 2;
// listen for DPI change, e.g. when dragging a browser window from a retina to non-retina screen
window.matchMedia(`
(-webkit-min-device-pixel-ratio: 2), /* Safari */
(min-resolution: 2dppx), /* standard */
(min-resolution: 192dpi) /* fallback */
`).addListener(function() {
isRetina = window.devicePixelRatio && window.devicePixelRatio >= 2;
});
function localeDateString(s) {
if (!s) return null;
var options = { day: 'numeric', month: 'short', year: 'numeric' };
var d = new Date(s);
if (isNaN(d.getTime())) return null;
return d.toLocaleDateString(localizer.localeCode(), options);
}
function vintageRange(vintage) {
var s;
if (vintage.start || vintage.end) {
s = (vintage.start || '?');
if (vintage.start !== vintage.end) {
s += ' - ' + (vintage.end || '?');
}
}
return s;
}
export function rendererBackgroundSource(data) {
var source = Object.assign({}, data); // shallow copy
var _offset = [0, 0];
var _name = source.name;
var _description = source.description;
var _best = !!source.best;
var _template = source.encrypted ? utilAesDecrypt(source.template) : source.template;
source.tileSize = data.tileSize || 256;
source.zoomExtent = data.zoomExtent || [0, 22];
source.overzoom = data.overzoom !== false;
source.offset = function(val) {
if (!arguments.length) return _offset;
_offset = val;
return source;
};
source.nudge = function(val, zoomlevel) {
_offset[0] += val[0] / Math.pow(2, zoomlevel);
_offset[1] += val[1] / Math.pow(2, zoomlevel);
return source;
};
source.name = function() {
var id_safe = source.id.replace(/\./g, '<TX_DOT>');
return t('imagery.' + id_safe + '.name', { default: _name });
};
source.label = function() {
var id_safe = source.id.replace(/\./g, '<TX_DOT>');
return t.html('imagery.' + id_safe + '.name', { default: _name });
};
source.description = function() {
var id_safe = source.id.replace(/\./g, '<TX_DOT>');
return t.html('imagery.' + id_safe + '.description', { default: _description });
};
source.best = function() {
return _best;
};
source.area = function() {
if (!data.polygon) return Number.MAX_VALUE; // worldwide
var area = d3_geoArea({ type: 'MultiPolygon', coordinates: [ data.polygon ] });
return isNaN(area) ? 0 : area;
};
source.imageryUsed = function() {
return _name || source.id;
};
source.template = function(val) {
if (!arguments.length) return _template;
if (source.id === 'custom' || source.id === 'Bing') {
_template = val;
}
return source;
};
source.url = function(coord) {
var result = _template;
if (result === '') return result; // source 'none'
// Guess a type based on the tokens present in the template
// (This is for 'custom' source, where we don't know)
if (!source.type) {
if (/SERVICE=WMS|\{(proj|wkid|bbox)\}/.test(_template)) {
source.type = 'wms';
source.projection = 'EPSG:3857'; // guess
} else if (/\{(x|y)\}/.test(_template)) {
source.type = 'tms';
} else if (/\{u\}/.test(_template)) {
source.type = 'bing';
}
}
if (source.type === 'wms') {
var tileToProjectedCoords = (function(x, y, z) {
//polyfill for IE11, PhantomJS
var sinh = Math.sinh || function(x) {
var y = Math.exp(x);
return (y - 1 / y) / 2;
};
var zoomSize = Math.pow(2, z);
var lon = x / zoomSize * Math.PI * 2 - Math.PI;
var lat = Math.atan(sinh(Math.PI * (1 - 2 * y / zoomSize)));
switch (source.projection) {
case 'EPSG:4326':
return {
x: lon * 180 / Math.PI,
y: lat * 180 / Math.PI
};
default: // EPSG:3857 and synonyms
var mercCoords = d3_geoMercatorRaw(lon, lat);
return {
x: 20037508.34 / Math.PI * mercCoords[0],
y: 20037508.34 / Math.PI * mercCoords[1]
};
}
});
var tileSize = source.tileSize;
var projection = source.projection;
var minXmaxY = tileToProjectedCoords(coord[0], coord[1], coord[2]);
var maxXminY = tileToProjectedCoords(coord[0]+1, coord[1]+1, coord[2]);
result = result.replace(/\{(\w+)\}/g, function (token, key) {
switch (key) {
case 'width':
case 'height':
return tileSize;
case 'proj':
return projection;
case 'wkid':
return projection.replace(/^EPSG:/, '');
case 'bbox':
// WMS 1.3 flips x/y for some coordinate systems including EPSG:4326 - #7557
if (projection === 'EPSG:4326' &&
// The CRS parameter implies version 1.3 (prior versions use SRS)
/VERSION=1.3|CRS={proj}/.test(source.template().toUpperCase())) {
return maxXminY.y + ',' + minXmaxY.x + ',' + minXmaxY.y + ',' + maxXminY.x;
} else {
return minXmaxY.x + ',' + maxXminY.y + ',' + maxXminY.x + ',' + minXmaxY.y;
}
case 'w':
return minXmaxY.x;
case 's':
return maxXminY.y;
case 'n':
return maxXminY.x;
case 'e':
return minXmaxY.y;
default:
return token;
}
});
} else if (source.type === 'tms') {
result = result
.replace('{x}', coord[0])
.replace('{y}', coord[1])
// TMS-flipped y coordinate
.replace(/\{[t-]y\}/, Math.pow(2, coord[2]) - coord[1] - 1)
.replace(/\{z(oom)?\}/, coord[2])
// only fetch retina tiles for retina screens
.replace(/\{@2x\}|\{r\}/, isRetina ? '@2x' : '');
} else if (source.type === 'bing') {
result = result
.replace('{u}', function() {
var u = '';
for (var zoom = coord[2]; zoom > 0; zoom--) {
var b = 0;
var mask = 1 << (zoom - 1);
if ((coord[0] & mask) !== 0) b++;
if ((coord[1] & mask) !== 0) b += 2;
u += b.toString();
}
return u;
});
}
// these apply to any type..
result = result.replace(/\{switch:([^}]+)\}/, function(s, r) {
var subdomains = r.split(',');
return subdomains[(coord[0] + coord[1]) % subdomains.length];
});
return result;
};
source.validZoom = function(z) {
return source.zoomExtent[0] <= z &&
(source.overzoom || source.zoomExtent[1] > z);
};
source.isLocatorOverlay = function() {
return source.id === 'mapbox_locator_overlay';
};
/* hides a source from the list, but leaves it available for use */
source.isHidden = function() {
return source.id === 'DigitalGlobe-Premium-vintage' ||
source.id === 'DigitalGlobe-Standard-vintage';
};
source.copyrightNotices = function() {};
source.getMetadata = function(center, tileCoord, callback) {
var vintage = {
start: localeDateString(source.startDate),
end: localeDateString(source.endDate)
};
vintage.range = vintageRange(vintage);
var metadata = { vintage: vintage };
callback(null, metadata);
};
return source;
}
rendererBackgroundSource.Bing = function(data, dispatch) {
// https://docs.microsoft.com/en-us/bingmaps/rest-services/imagery/get-imagery-metadata
// https://docs.microsoft.com/en-us/bingmaps/rest-services/directly-accessing-the-bing-maps-tiles
//fallback url template
data.template = 'https://ecn.t{switch:0,1,2,3}.tiles.virtualearth.net/tiles/a{u}.jpeg?g=587&n=z';
var bing = rendererBackgroundSource(data);
//var key = 'Arzdiw4nlOJzRwOz__qailc8NiR31Tt51dN2D7cm57NrnceZnCpgOkmJhNpGoppU'; // P2, JOSM, etc
var key = 'Ak5oTE46TUbjRp08OFVcGpkARErDobfpuyNKa-W2mQ8wbt1K1KL8p1bIRwWwcF-Q'; // iD
/*
missing tile image strictness param (n=)
• n=f -> (Fail) returns a 404
• n=z -> (Empty) returns a 200 with 0 bytes (no content)
• n=t -> (Transparent) returns a 200 with a transparent (png) tile
*/
const strictParam = 'n';
var url = 'https://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial?include=ImageryProviders&uriScheme=https&key=' + key;
var cache = {};
var inflight = {};
var providers = [];
d3_json(url)
.then(function(json) {
let imageryResource = json.resourceSets[0].resources[0];
//retrieve and prepare up to date imagery template
let template = imageryResource.imageUrl; //https://ecn.{subdomain}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=10339
let subDomains = imageryResource.imageUrlSubdomains; //["t0, t1, t2, t3"]
let subDomainNumbers = subDomains.map((subDomain) => {
return subDomain.substring(1);
} ).join(',');
template = template.replace('{subdomain}', `t{switch:${subDomainNumbers}}`).replace('{quadkey}', '{u}');
if (!new URLSearchParams(template).has(strictParam)){
template += `&${strictParam}=z`;
}
bing.template(template);
providers = imageryResource.imageryProviders.map(function(provider) {
return {
attribution: provider.attribution,
areas: provider.coverageAreas.map(function(area) {
return {
zoom: [area.zoomMin, area.zoomMax],
extent: new Extent([area.bbox[1], area.bbox[0]], [area.bbox[3], area.bbox[2]])
};
})
};
});
dispatch.call('change');
})
.catch(function() {
/* ignore */
});
bing.copyrightNotices = function(zoom, extent) {
zoom = Math.min(zoom, 21);
return providers.filter(function(provider) {
return provider.areas.some(function(area) {
return extent.intersects(area.extent) &&
area.zoom[0] <= zoom &&
area.zoom[1] >= zoom;
});
}).map(function(provider) {
return provider.attribution;
}).join(', ');
};
bing.getMetadata = function(center, tileCoord, callback) {
var tileID = tileCoord.slice(0, 3).join('/');
var zoom = Math.min(tileCoord[2], 21);
var centerPoint = center[1] + ',' + center[0]; // lat,lng
var url = 'https://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial/' + centerPoint +
'?zl=' + zoom + '&key=' + key;
if (inflight[tileID]) return;
if (!cache[tileID]) {
cache[tileID] = {};
}
if (cache[tileID] && cache[tileID].metadata) {
return callback(null, cache[tileID].metadata);
}
inflight[tileID] = true;
d3_json(url)
.then(function(result) {
delete inflight[tileID];
if (!result) {
throw new Error('Unknown Error');
}
var vintage = {
start: localeDateString(result.resourceSets[0].resources[0].vintageStart),
end: localeDateString(result.resourceSets[0].resources[0].vintageEnd)
};
vintage.range = vintageRange(vintage);
var metadata = { vintage: vintage };
cache[tileID].metadata = metadata;
if (callback) callback(null, metadata);
})
.catch(function(err) {
delete inflight[tileID];
if (callback) callback(err.message);
});
};
bing.terms_url = 'https://blog.openstreetmap.org/2010/11/30/microsoft-imagery-details';
return bing;
};
rendererBackgroundSource.Esri = function(data) {
// in addition to using the tilemap at zoom level 20, overzoom real tiles - #4327 (deprecated technique, but it works)
if (data.template.match(/blankTile/) === null) {
data.template = data.template + '?blankTile=false';
}
var esri = rendererBackgroundSource(data);
var cache = {};
var inflight = {};
var _prevCenter;
// use a tilemap service to set maximum zoom for esri tiles dynamically
// https://developers.arcgis.com/documentation/tiled-elevation-service/
esri.fetchTilemap = function(center) {
// skip if we have already fetched a tilemap within 5km
if (_prevCenter && geoSphericalDistance(center, _prevCenter) < 5000) return;
_prevCenter = center;
// tiles are available globally to zoom level 19, afterward they may or may not be present
var z = 20;
// first generate a random url using the template
var dummyUrl = esri.url([1,2,3]);
// calculate url z/y/x from the lat/long of the center of the map
var x = (Math.floor((center[0] + 180) / 360 * Math.pow(2, z)));
var y = (Math.floor((1 - Math.log(Math.tan(center[1] * Math.PI / 180) + 1 / Math.cos(center[1] * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, z)));
// fetch an 8x8 grid to leverage cache
var tilemapUrl = dummyUrl.replace(/tile\/[0-9]+\/[0-9]+\/[0-9]+\?blankTile=false/, 'tilemap') + '/' + z + '/' + y + '/' + x + '/8/8';
// make the request and introspect the response from the tilemap server
d3_json(tilemapUrl)
.then(function(tilemap) {
if (!tilemap) {
throw new Error('Unknown Error');
}
var hasTiles = true;
for (var i = 0; i < tilemap.data.length; i++) {
// 0 means an individual tile in the grid doesn't exist
if (!tilemap.data[i]) {
hasTiles = false;
break;
}
}
// if any tiles are missing at level 20 we restrict maxZoom to 19
esri.zoomExtent[1] = (hasTiles ? 22 : 19);
})
.catch(function() {
/* ignore */
});
};
esri.getMetadata = function(center, tileCoord, callback) {
var tileID = tileCoord.slice(0, 3).join('/');
var zoom = Math.min(tileCoord[2], esri.zoomExtent[1]);
var centerPoint = center[0] + ',' + center[1]; // long, lat (as it should be)
var unknown = t('info_panels.background.unknown');
var metadataLayer;
var vintage = {};
var metadata = {};
if (inflight[tileID]) return;
switch (true) {
case (zoom >= 20 && esri.id === 'EsriWorldImageryClarity'):
metadataLayer = 4;
break;
case zoom >= 19:
metadataLayer = 3;
break;
case zoom >= 17:
metadataLayer = 2;
break;
case zoom >= 13:
metadataLayer = 0;
break;
default:
metadataLayer = 99;
}
var url;
// build up query using the layer appropriate to the current zoom
if (esri.id === 'EsriWorldImagery') {
url = 'https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/';
} else if (esri.id === 'EsriWorldImageryClarity') {
url = 'https://serviceslab.arcgisonline.com/arcgis/rest/services/Clarity_World_Imagery/MapServer/';
}
url += metadataLayer + '/query?returnGeometry=false&geometry=' + centerPoint + '&inSR=4326&geometryType=esriGeometryPoint&outFields=*&f=json';
if (!cache[tileID]) {
cache[tileID] = {};
}
if (cache[tileID] && cache[tileID].metadata) {
return callback(null, cache[tileID].metadata);
}
// accurate metadata is only available >= 13
if (metadataLayer === 99) {
vintage = {
start: null,
end: null,
range: null
};
metadata = {
vintage: null,
source: unknown,
description: unknown,
resolution: unknown,
accuracy: unknown
};
callback(null, metadata);
} else {
inflight[tileID] = true;
d3_json(url)
.then(function(result) {
delete inflight[tileID];
if (!result) {
throw new Error('Unknown Error');
} else if (result.features && result.features.length < 1) {
throw new Error('No Results');
} else if (result.error && result.error.message) {
throw new Error(result.error.message);
}
// pass through the discrete capture date from metadata
var captureDate = localeDateString(result.features[0].attributes.SRC_DATE2);
vintage = {
start: captureDate,
end: captureDate,
range: captureDate
};
metadata = {
vintage: vintage,
source: clean(result.features[0].attributes.NICE_NAME),
description: clean(result.features[0].attributes.NICE_DESC),
resolution: clean(+parseFloat(result.features[0].attributes.SRC_RES).toFixed(4)),
accuracy: clean(+parseFloat(result.features[0].attributes.SRC_ACC).toFixed(4))
};
// append units - meters
if (isFinite(metadata.resolution)) {
metadata.resolution += ' m';
}
if (isFinite(metadata.accuracy)) {
metadata.accuracy += ' m';
}
cache[tileID].metadata = metadata;
if (callback) callback(null, metadata);
})
.catch(function(err) {
delete inflight[tileID];
if (callback) callback(err.message);
});
}
function clean(val) {
return String(val).trim() || unknown;
}
};
return esri;
};
rendererBackgroundSource.None = function() {
var source = rendererBackgroundSource({ id: 'none', template: '' });
source.name = function() {
return t('background.none');
};
source.label = function() {
return t.html('background.none');
};
source.imageryUsed = function() {
return null;
};
source.area = function() {
return -1; // sources in background pane are sorted by area
};
return source;
};
rendererBackgroundSource.Custom = function(template) {
var source = rendererBackgroundSource({ id: 'custom', template: template });
source.name = function() {
return t('background.custom');
};
source.label = function() {
return t.html('background.custom');
};
source.imageryUsed = function() {
// sanitize personal connection tokens - #6801
var cleaned = source.template();
// from query string parameters
if (cleaned.indexOf('?') !== -1) {
var parts = cleaned.split('?', 2);
var qs = utilStringQs(parts[1]);
['access_token', 'connectId', 'token'].forEach(function(param) {
if (qs[param]) {
qs[param] = '{apikey}';
}
});
cleaned = parts[0] + '?' + utilQsString(qs, true); // true = soft encode
}
// from wms/wmts api path parameters
cleaned = cleaned.replace(/token\/(\w+)/, 'token/{apikey}');
return 'Custom (' + cleaned + ' )';
};
source.area = function() {
return -2; // sources in background pane are sorted by area
};
return source;
};