tangram
Version:
WebGL Maps for Vector Tiles
1,487 lines (1,390 loc) • 1.1 MB
JavaScript
// define() gets called for each chunk generated by the first Rollup pass.
// The order the chunks are called in is controlled by the imports in bundle.js:
//
// shared.js: shared dependencies between main and worker threads
// scene_worker.js: worker thread code
// index.js: main thread code
// Once all chunks have been provided, the worker thread code is assembled,
// incorporating the shared chunk code, then turned into a blob URL which
// can be used to instantiate the worker.
var shared, worker, Tangram = {};
function define(_, chunk) {
if (!shared) {
shared = chunk;
} else if (!worker) {
worker = chunk;
} else {
var worker_bundle = 'var shared_chunk = {}; (' + shared + ')(shared_chunk); (' + worker + ')(shared_chunk);'
var shared_chunk = {};
shared(shared_chunk);
Tangram = chunk(shared_chunk);
Tangram.workerURL = window.URL.createObjectURL(new Blob([worker_bundle], { type: 'text/javascript' }));
}
}
define(['exports'], (function (exports) { 'use strict';
/*jshint worker: true*/
// Mark thread as main or worker
const Thread = {};
try {
if (window instanceof Window && window.document instanceof HTMLDocument) {
// jshint ignore:line
Thread.is_worker = false;
Thread.is_main = true;
}
} catch (e) {
Thread.is_worker = true;
Thread.is_main = false;
// Patch for 3rd party libs that require these globals to be present. Specifically, FontFaceObserver.
// Brittle solution but allows that library to load on worker threads.
self.window = {
document: {}
};
self.document = self.window.document;
}
function _typeof(o) {
"@babel/helpers - typeof";
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) {
return typeof o;
} : function (o) {
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
}, _typeof(o);
}
function toPrimitive(t, r) {
if ("object" != _typeof(t) || !t) return t;
var e = t[Symbol.toPrimitive];
if (void 0 !== e) {
var i = e.call(t, r || "default");
if ("object" != _typeof(i)) return i;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return ("string" === r ? String : Number)(t);
}
function toPropertyKey(t) {
var i = toPrimitive(t, "string");
return "symbol" == _typeof(i) ? i : i + "";
}
function _defineProperty(e, r, t) {
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
value: t,
enumerable: !0,
configurable: !0,
writable: !0
}) : e[r] = t, e;
}
var version$1 = "0.22.0";
var version = 'v' + version$1;
/*jshint worker: true*/
var WorkerBroker;
var WorkerBroker$1 = WorkerBroker = {};
// Global list of all worker messages
// Uniquely tracks every call made between main thread and a worker
var message_id = 0;
var messages = {};
// Register an object to receive calls from other thread
WorkerBroker.targets = {};
WorkerBroker.addTarget = function (name, target) {
WorkerBroker.targets[name] = target;
};
WorkerBroker.removeTarget = function (name) {
if (name) {
delete WorkerBroker.targets[name];
}
};
// Given a dot-notation-style method name, e.g. 'Object.object.method',
// find the object to call the method on from the list of registered targets
function findTarget(method) {
var chain = [];
if (typeof method === 'string') {
chain = method.split('.');
method = chain.pop();
}
var target = WorkerBroker.targets;
for (let m = 0; m < chain.length; m++) {
if (target[chain[m]]) {
target = target[chain[m]];
} else {
return [];
}
}
return [method, target];
}
// Main thread:
// - Send messages to workers, and optionally receive an async response as a promise
// - Receive messages from workers, and optionally send an async response back as a promise
function setupMainThread() {
// Send a message to a worker, and optionally get an async response
// Arguments:
// - worker: one or more web worker instances to send the message to (single value or array)
// - method: the method with this name, specified with dot-notation, will be invoked in the worker
// - message: spread of arguments to call the method with
// Returns:
// - a promise that will be fulfilled if the worker method returns a value (could be immediately, or async)
//
WorkerBroker.postMessage = function (worker, method, ...message) {
// If more than one worker specified, post to multiple
if (Array.isArray(worker)) {
return Promise.all(worker.map(w => WorkerBroker.postMessage(w, method, ...message)));
}
// Parse options
let options = {};
if (typeof method === 'object') {
options = method;
method = method.method;
}
// Track state of this message
var promise = new Promise((resolve, reject) => {
messages[message_id] = {
method,
message,
resolve,
reject
};
});
let payload,
transferables = [];
if (message && message.length === 1 && message[0] instanceof WorkerBroker.withTransferables) {
transferables = message[0].transferables;
message = message[0].value;
}
payload = {
type: 'main_send',
// mark message as method invocation from main thread
message_id,
// unique id for this message, for life of program
method,
// will dispatch to a function of this name within the worker
message // message payload
};
if (options.stringify) {
payload = JSON.stringify(payload);
}
worker.postMessage(payload, transferables.map(t => t.object));
freeTransferables(transferables);
if (transferables.length > 0) {
log('trace', `'${method}' transferred ${transferables.length} objects to worker thread`);
}
message_id++;
return promise;
};
// Add a worker to communicate with - each worker must be registered from the main thread
WorkerBroker.addWorker = function (worker) {
if (!(worker instanceof Worker)) {
throw Error('Worker broker could not add non-Worker object', worker);
}
worker.addEventListener('message', function WorkerBrokerMainThreadHandler(event) {
let data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
let id = data.message_id;
// Listen for messages coming back from the worker, and fulfill that message's promise
if (data.type === 'worker_reply') {
// Pass the result to the promise
if (messages[id]) {
if (data.error) {
messages[id].reject(data.error);
} else {
messages[id].resolve(data.message);
}
delete messages[id];
}
}
// Listen for messages initiating a call from the worker, dispatch them,
// and send any return value back to the worker
// Unique id for this message & return call to main thread
else if (data.type === 'worker_send' && id != null) {
// Call the requested method and save the return value
let result, error, target, method_name, method;
try {
[method_name, target] = findTarget(data.method);
if (!target) {
throw Error(`Worker broker could not dispatch message type ${data.method} on target ${data.target} because no object with that name is registered on main thread`);
}
method = typeof target[method_name] === 'function' && target[method_name];
if (!method) {
throw Error(`Worker broker could not dispatch message type ${data.method} on target ${data.target} because object has no method with that name`);
}
result = method.apply(target, data.message);
} catch (e) {
// Thrown errors will be passed back (in string form) to worker
error = e;
}
// Send return value to worker
let payload,
transferables = [];
// Async result
if (result instanceof Promise) {
result.then(value => {
if (value instanceof WorkerBroker.withTransferables) {
transferables = value.transferables;
value = value.value[0];
}
payload = {
type: 'main_reply',
message_id: id,
message: value
};
worker.postMessage(payload, transferables.map(t => t.object));
freeTransferables(transferables);
if (transferables.length > 0) {
log('trace', `'${method_name}' transferred ${transferables.length} objects to worker thread`);
}
}, error => {
worker.postMessage({
type: 'main_reply',
message_id: id,
error: error instanceof Error ? `${error.message}: ${error.stack}` : error
});
});
}
// Immediate result
else {
if (result instanceof WorkerBroker.withTransferables) {
transferables = result.transferables;
result = result.value[0];
}
payload = {
type: 'main_reply',
message_id: id,
message: result,
error: error instanceof Error ? `${error.message}: ${error.stack}` : error
};
worker.postMessage(payload, transferables.map(t => t.object));
freeTransferables(transferables);
if (transferables.length > 0) {
log('trace', `'${method_name}' transferred ${transferables.length} objects to worker thread`);
}
}
}
});
};
// Expose for debugging
WorkerBroker.getMessages = function () {
return messages;
};
WorkerBroker.getMessageId = function () {
return message_id;
};
}
// Worker threads:
// - Receive messages from main thread, and optionally send an async response back as a promise
// - Send messages to main thread, and optionally receive an async response as a promise
function setupWorkerThread() {
// Send a message to the main thread, and optionally get an async response as a promise
// Arguments:
// - method: the method with this name, specified with dot-notation, will be invoked on the main thread
// - message: array of arguments to call the method with
// Returns:
// - a promise that will be fulfilled if the main thread method returns a value (could be immediately, or async)
//
WorkerBroker.postMessage = function (method, ...message) {
// Parse options
let options = {};
if (typeof method === 'object') {
options = method;
method = method.method;
}
// Track state of this message
var promise = new Promise((resolve, reject) => {
messages[message_id] = {
method,
message,
resolve,
reject
};
});
let payload,
transferables = [];
if (message && message.length === 1 && message[0] instanceof WorkerBroker.withTransferables) {
transferables = message[0].transferables;
message = message[0].value;
}
payload = {
type: 'worker_send',
// mark message as method invocation from worker
message_id,
// unique id for this message, for life of program
method,
// will dispatch to a method of this name on the main thread
message // message payload
};
if (options.stringify) {
payload = JSON.stringify(payload);
}
self.postMessage(payload, transferables.map(t => t.object));
freeTransferables(transferables);
if (transferables.length > 0) {
log('trace', `'${method}' transferred ${transferables.length} objects to main thread`);
}
message_id++;
return promise;
};
self.addEventListener('message', function WorkerBrokerWorkerThreadHandler(event) {
let data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
let id = data.message_id;
// Listen for messages coming back from the main thread, and fulfill that message's promise
if (data.type === 'main_reply') {
// Pass the result to the promise
if (messages[id]) {
if (data.error) {
messages[id].reject(data.error);
} else {
messages[id].resolve(data.message);
}
delete messages[id];
}
}
// Receive messages from main thread, dispatch them, and send back a reply
// Unique id for this message & return call to main thread
else if (data.type === 'main_send' && id != null) {
// Call the requested worker method and save the return value
let result, error, target, method_name, method;
try {
[method_name, target] = findTarget(data.method);
if (!target) {
throw Error(`Worker broker could not dispatch message type ${data.method} on target ${data.target} because no object with that name is registered on main thread`);
}
method = typeof target[method_name] === 'function' && target[method_name];
if (!method) {
throw Error(`Worker broker could not dispatch message type ${data.method} because worker has no method with that name`);
}
result = method.apply(target, data.message);
} catch (e) {
// Thrown errors will be passed back (in string form) to main thread
error = e;
}
// Send return value to main thread
let payload,
transferables = [];
// Async result
if (result instanceof Promise) {
result.then(value => {
if (value instanceof WorkerBroker.withTransferables) {
transferables = value.transferables;
value = value.value[0];
}
payload = {
type: 'worker_reply',
message_id: id,
message: value
};
self.postMessage(payload, transferables.map(t => t.object));
freeTransferables(transferables);
if (transferables.length > 0) {
log('trace', `'${method_name}' transferred ${transferables.length} objects to main thread`);
}
}, error => {
self.postMessage({
type: 'worker_reply',
message_id: id,
error: error instanceof Error ? `${error.message}: ${error.stack}` : error
});
});
}
// Immediate result
else {
if (result instanceof WorkerBroker.withTransferables) {
transferables = result.transferables;
result = result.value[0];
}
payload = {
type: 'worker_reply',
message_id: id,
message: result,
error: error instanceof Error ? `${error.message}: ${error.stack}` : error
};
self.postMessage(payload, transferables.map(t => t.object));
freeTransferables(transferables);
if (transferables.length > 0) {
log('trace', `'${method_name}' transferred ${transferables.length} objects to main thread`);
}
}
}
});
}
// Special value wrapper, to indicate that we want to find and include transferable objects in the message
WorkerBroker.withTransferables = function (...value) {
if (!(this instanceof WorkerBroker.withTransferables)) {
return new WorkerBroker.withTransferables(...value);
}
this.value = value;
this.transferables = findTransferables(this.value);
};
// Build a list of transferable objects from a source object
// Returns a list of info about each transferable:
// - object: the actual transferable object
// - parent: the parent object that the transferable is a property of (if any)
// - property: the property name of the transferable on the parent object (if any)
// TODO: add option in case you DON'T want to transfer objects
function findTransferables(source, parent = null, property = null, list = []) {
if (!source) {
return list;
}
if (Array.isArray(source)) {
// Check each array element
source.forEach((x, i) => findTransferables(x, source, i, list));
} else if (typeof source === 'object') {
// Is the object a transferable array buffer?
if (source instanceof ArrayBuffer) {
list.push({
object: source,
parent,
property
});
}
// Or looks like a typed array (has an array buffer property)?
else if (source.buffer instanceof ArrayBuffer) {
list.push({
object: source.buffer,
parent,
property
});
}
// Otherwise check each property
else {
for (let prop in source) {
findTransferables(source[prop], source, prop, list);
}
}
}
return list;
}
// Remove neutered transferables from parent objects, as they should no longer be accessed after transfer
function freeTransferables(transferables) {
if (!Array.isArray(transferables)) {
return;
}
transferables.filter(t => t.parent && t.property).forEach(t => delete t.parent[t.property]);
}
// Setup this thread as appropriate
if (Thread.is_main) {
setupMainThread();
}
if (Thread.is_worker) {
setupWorkerThread();
}
const LEVELS = {
silent: -1,
error: 0,
warn: 1,
info: 2,
debug: 3,
trace: 4
};
const methods = {};
let logged_once = {};
function methodForLevel(level) {
if (Thread.is_main) {
methods[level] = methods[level] || (console[level] ? console[level] : console.log).bind(console); // eslint-disable-line no-console
return methods[level];
}
}
// Logs message, proxying any log requests from worker threads back to the main thread.
// Returns (asynchronously, due to proxying) a boolean indicating if the message was logged.
// Option `once: true` can be used to only log each unique log message once (e.g. for warnings
// that would otherwise be repetitive or possibly logged thousands of times, such as per feature).
function log(opts, ...msg) {
let level = typeof opts === 'object' ? opts.level : opts;
if (LEVELS[level] <= LEVELS[log.level]) {
if (Thread.is_worker) {
// Proxy to main thread
return WorkerBroker$1.postMessage({
method: '_logProxy',
stringify: true
}, opts, ...msg);
} else {
// Only log message once?
if (typeof opts === 'object' && opts.once === true) {
if (logged_once[JSON.stringify(msg)]) {
return Promise.resolve(false);
}
logged_once[JSON.stringify(msg)] = true;
}
// Write to console (on main thread)
let logger = methodForLevel(level);
if (msg.length > 1) {
logger(`Tangram ${version} [${level}]: ${msg[0]}`, ...msg.slice(1));
} else {
logger(`Tangram ${version} [${level}]: ${msg[0]}`);
}
}
return Promise.resolve(true);
}
return Promise.resolve(false);
}
log.level = 'info';
log.workers = null;
log.setLevel = function (level) {
log.level = level;
if (Thread.is_main && Array.isArray(log.workers)) {
WorkerBroker$1.postMessage(log.workers, '_logSetLevelProxy', level);
}
};
if (Thread.is_main) {
log.setWorkers = function (workers) {
log.workers = workers;
};
log.reset = function () {
logged_once = {};
};
}
WorkerBroker$1.addTarget('_logProxy', log); // proxy log messages from worker to main thread
WorkerBroker$1.addTarget('_logSetLevelProxy', log.setLevel); // proxy log level setting from main to worker thread
// Miscellaneous utilities
/*jshint worker: true*/
const Utils = {};
WorkerBroker$1.addTarget('Utils', Utils);
// Basic Safari detection
// http://stackoverflow.com/questions/7944460/detect-safari-browser
Utils.isSafari = function () {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
};
// Basic IE11 or Edge detection
Utils.isMicrosoft = function () {
return /(Trident\/7.0|Edge[ /](\d+[.\d]+))/i.test(navigator.userAgent);
};
Utils._requests = {}; // XHR requests on current thread
Utils._proxy_requests = {}; // XHR requests proxied to main thread
// `request_key` is a user-provided key that can be later used to cancel the request
Utils.io = function (url, timeout = 60000, responseType = 'text', method = 'GET', headers = {}, request_key = null, proxy = false) {
if (Thread.is_worker && Utils.isMicrosoft()) {
// Some versions of IE11 and Edge will hang web workers when performing XHR requests
// These requests can be proxied through the main thread
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9545866/
log('debug', 'Proxying request for URL to worker', url);
if (request_key) {
Utils._proxy_requests[request_key] = true; // mark as proxied
}
return WorkerBroker$1.postMessage('Utils.io', url, timeout, responseType, method, headers, request_key, true);
} else {
var request = new XMLHttpRequest();
var promise = new Promise((resolve, reject) => {
request.open(method, url, true);
request.timeout = timeout;
request.responseType = responseType;
// Attach optional request headers
if (headers && typeof headers === 'object') {
for (let key in headers) {
request.setRequestHeader(key, headers[key]);
}
}
request.onload = () => {
if (request.status === 200) {
if (['text', 'json'].indexOf(request.responseType) > -1) {
resolve({
body: request.responseText,
status: request.status
});
} else {
resolve({
body: request.response,
status: request.status
});
}
} else if (request.status === 204) {
// No Content
resolve({
body: null,
status: request.status
});
} else {
reject(Error('Request error with a status of ' + request.statusText));
}
};
request.onerror = evt => {
reject(Error('There was a network error' + evt.toString()));
};
request.ontimeout = evt => {
reject(Error('timeout ' + evt.toString()));
};
request.send();
});
promise = promise.then(response => {
if (request_key) {
delete Utils._requests[request_key];
}
if (proxy) {
return WorkerBroker$1.withTransferables(response);
}
return response;
});
if (request_key) {
Utils._requests[request_key] = request;
}
return promise;
}
};
// Çancel a pending network request by user-provided request key
Utils.cancelRequest = function (key) {
// Check for a request that was proxied to the main thread
if (Thread.is_worker && Utils._proxy_requests[key]) {
return WorkerBroker$1.postMessage('Utils.cancelRequest', key); // forward to main thread
}
let req = Utils._requests[key];
if (req) {
log('trace', `Cancelling network request key '${key}'`);
Utils._requests[key].abort();
delete Utils._requests[key];
} else {
log('trace', `Could not find network request key '${key}'`);
}
};
// Stringify an object into JSON, but convert functions to strings
Utils.serializeWithFunctions = function (obj) {
if (typeof obj === 'function') {
return obj.toString();
}
let serialized = JSON.stringify(obj, function (k, v) {
// Convert functions to strings
if (typeof v === 'function') {
return v.toString();
}
return v;
});
return serialized;
};
// Default to allowing high pixel density
// Returns true if display density changed
Utils.use_high_density_display = true;
Utils.updateDevicePixelRatio = function () {
let prev = Utils.device_pixel_ratio;
Utils.device_pixel_ratio = Utils.use_high_density_display && window.devicePixelRatio || 1;
return Utils.device_pixel_ratio !== prev;
};
if (Thread.is_main) {
Utils.updateDevicePixelRatio();
}
// Used for differentiating between power-of-2 and non-power-of-2 textures
// Via: http://stackoverflow.com/questions/19722247/webgl-wait-for-texture-to-load
Utils.isPowerOf2 = function (value) {
return (value & value - 1) === 0;
};
// Interpolate 'x' along a series of control points
// 'points' is an array of control points in the form [x, y]
//
// Example:
// Control points:
// [0, 5]: when x=0, y=5
// [4, 10]: when x=4, y=10
//
// Utils.interpolate(2, [[0, 5], [4, 10]]);
// -> computes x=2, halfway between x=0 and x=4: (10 - 5) / 2 +5
// -> returns 7.5
//
// TODO: add other interpolation methods besides linear
//
Utils.interpolate = function (x, points, transform) {
// If this doesn't resemble a list of control points, just return the original value
if (!Array.isArray(points) || !Array.isArray(points[0])) {
return points;
} else if (points.length < 1) {
return points;
}
var x1, x2, d, y, y1, y2;
// Min bounds
if (x <= points[0][0]) {
y = points[0][1];
if (typeof transform === 'function') {
y = transform(y);
}
}
// Max bounds
else if (x >= points[points.length - 1][0]) {
y = points[points.length - 1][1];
if (typeof transform === 'function') {
y = transform(y);
}
}
// Find which control points x is between
else {
for (var i = 0; i < points.length - 1; i++) {
if (x >= points[i][0] && x < points[i + 1][0]) {
// Linear interpolation
x1 = points[i][0];
x2 = points[i + 1][0];
// Multiple values
if (Array.isArray(points[i][1])) {
y = [];
for (var c = 0; c < points[i][1].length; c++) {
if (typeof transform === 'function') {
y1 = transform(points[i][1][c]);
y2 = transform(points[i + 1][1][c]);
d = y2 - y1;
y[c] = d * (x - x1) / (x2 - x1) + y1;
} else {
d = points[i + 1][1][c] - points[i][1][c];
y[c] = d * (x - x1) / (x2 - x1) + points[i][1][c];
}
}
}
// Single value
else {
if (typeof transform === 'function') {
y1 = transform(points[i][1]);
y2 = transform(points[i + 1][1]);
d = y2 - y1;
y = d * (x - x1) / (x2 - x1) + y1;
} else {
d = points[i + 1][1] - points[i][1];
y = d * (x - x1) / (x2 - x1) + points[i][1];
}
}
break;
}
}
}
return y;
};
Utils.toCSSColor = function (color) {
if (color != null) {
if (color[3] === 1) {
// full opacity
return `rgb(${color.slice(0, 3).map(c => Math.round(c * 255)).join(', ')})`;
}
// RGB is between [0, 255] opacity is between [0, 1]
return `rgba(${color.map((c, i) => i < 3 && Math.round(c * 255) || c).join(', ')})`;
}
};
let debugSettings;
var debugSettings$1 = debugSettings = {
// draws a blue rectangle border around the collision box of a label
draw_label_collision_boxes: false,
// draws a green rectangle border within the texture box of a label
draw_label_texture_boxes: false,
// suppreses fade-in of labels
suppress_label_fade_in: false,
// suppress animaton of label snap to pixel grid
suppress_label_snap_animation: false,
// show hidden labels for debugging
show_hidden_labels: false,
// collect feature/geometry stats on styling layers
layer_stats: false,
// draw scene in wireframe mode
wireframe: false
};
function mergeDebugSettings(settings) {
Object.assign(debugSettings, settings);
}
// Adds a base origin to relative URLs
function addBaseURL(url, base) {
if (!url || !isRelativeURL(url)) {
return url;
}
var relative_path = url[0] !== '/';
var base_info;
if (base) {
base_info = document.createElement('a'); // use a temporary element to parse URL
base_info.href = base;
} else {
base_info = window.location;
}
if (relative_path) {
let path = pathForURL(base_info.href);
url = path + url;
} else {
let origin = base_info.origin;
if (!origin) {
origin = base_info.protocol + '//' + base_info.host; // IE11 doesn't have origin property
}
url = origin + url;
}
return url;
}
function pathForURL(url) {
if (typeof url === 'string' && url.search(/^(data|blob):/) === -1) {
let qs = url.indexOf('?');
if (qs > -1) {
url = url.substr(0, qs);
}
let hash = url.indexOf('#');
if (hash > -1) {
url = url.substr(0, hash);
}
return url.substr(0, url.lastIndexOf('/') + 1) || '';
}
return '';
}
function extensionForURL(url) {
url = url.split('/').pop();
let last_dot = url.lastIndexOf('.');
if (last_dot > -1) {
return url.substring(last_dot + 1);
}
}
function isLocalURL(url) {
if (typeof url !== 'string') {
return;
}
return url.search(/^(data|blob):/) > -1;
}
function isRelativeURL(url) {
if (typeof url !== 'string') {
return;
}
return !(url.search(/^(http|https|data|blob):/) > -1 || url.substr(0, 2) === '//');
}
// Resolves './' and '../' components from relative path, to get a "flattened" path
function flattenRelativeURL(url) {
let dirs = (url || '').split('/');
for (let d = 1; d < dirs.length; d++) {
if (dirs[d] === '.') {
dirs.splice(d, 1);
d--;
} else if (dirs[d] === '..') {
d = d + 0;
dirs.splice(d - 1, 2);
d--;
}
}
return dirs.join('/');
}
// Add a set of query string params to a URL
// params: hash of key/value pairs of query string parameters
// returns array of: [modified URL, array of duplicate param name and values]
function addParamsToURL(url, params) {
if (!params || Object.keys(params).length === 0) {
return [url, []];
}
var qs_index = url.indexOf('?');
var hash_index = url.indexOf('#');
// Save and trim hash
var hash = '';
if (hash_index > -1) {
hash = url.slice(hash_index);
url = url.slice(0, hash_index);
}
// Start query string
if (qs_index === -1) {
qs_index = url.length;
url += '?';
}
qs_index++; // advanced past '?'
// Build query string params
var url_params = '';
var dupes = [];
for (var p in params) {
if (getURLParameter(p, url) !== '') {
dupes.push([p, params[p]]);
continue;
}
url_params += `${p}=${params[p]}&`;
}
// Insert new query string params and restore hash
url = url.slice(0, qs_index) + url_params + url.slice(qs_index) + hash;
return [url, dupes];
}
// Polyfill (for Safari compatibility)
let _createObjectURL;
function createObjectURL(url) {
if (_createObjectURL === undefined) {
_createObjectURL = window.URL && window.URL.createObjectURL || window.webkitURL && window.webkitURL.createObjectURL;
if (typeof _createObjectURL !== 'function') {
_createObjectURL = null;
log('warn', 'window.URL.createObjectURL (or vendor prefix) not found, unable to create local blob URLs');
}
}
if (_createObjectURL) {
return _createObjectURL(url);
} else {
return url;
}
}
// Via https://davidwalsh.name/query-string-javascript
function getURLParameter(name, url) {
name = name.replace(/[[]/, '\\[').replace(/[\]]/, '\\]');
var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
var results = regex.exec(url);
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
}
// import log from './log';
const Task = {
id: 0,
// unique id per task
queue: [],
// current queue of outstanding tasks
max_time: 20,
// default time in which all tasks should complete per frame
start_time: null,
// start time for tasks in current frame
state: {},
// track flags about environment state (ex: whether user is currently moving the view)
add(task) {
task.id = Task.id++;
task.max_time = task.max_time || Task.max_time; // allow task to run for this much time (tasks have a global collective limit per frame, too)
task.pause_factor = task.pause_factor || 1; // pause tasks by this many frames when they run too long
let promise = new Promise((resolve, reject) => {
task.resolve = resolve;
task.reject = reject;
});
task.promise = promise;
task.elapsed = 0;
task.total_elapsed = 0;
task.stats = {
calls: 0
};
this.queue.push(task);
// Run task immediately if under total frame time
this.start_time = this.start_time || performance.now(); // start frame timer if necessary
this.elapsed = performance.now() - this.start_time;
if (this.elapsed < Task.max_time || task.immediate) {
this.process(task);
}
return task.promise;
},
remove(task) {
let idx = this.queue.indexOf(task);
if (idx > -1) {
this.queue.splice(idx, 1);
}
},
process(task) {
// Skip task while user is moving the view, if the task requests it
// (for intensive tasks that lock the UI, like canvas rasterization)
if (this.state.user_moving_view && task.user_moving_view === false) {
// log('debug', `*** SKIPPING task id ${task.id}, ${task.type} while user is moving view`);
return;
}
// Skip task if it's currently paused
if (task.pause) {
// log('debug', `*** PAUSING task id ${task.id}, ${task.type} (${task.pause})`);
task.pause--;
return true;
}
task.stats.calls++;
task.start_time = performance.now(); // start task timer
return task.run(task);
},
processAll() {
this.start_time = this.start_time || performance.now(); // start frame timer if necessary
for (let i = 0; i < this.queue.length; i++) {
// Exceeded either total task time, or total frame time
let task = this.queue[i];
if (this.process(task) !== true) {
// If the task didn't complete, pause it for a task-specific number of frames
// (can be disabled by setting pause_factor to 0)
if (!task.pause) {
task.pause = task.elapsed > task.max_time ? task.pause_factor : 0;
}
task.total_elapsed += task.elapsed;
}
// Check total frame time
this.elapsed = performance.now() - this.start_time;
if (this.elapsed >= Task.max_time) {
this.start_time = null; // reset frame timer
break;
}
}
},
finish(task, value) {
task.elapsed = performance.now() - task.start_time;
task.total_elapsed += task.elapsed;
// log('debug', `task type ${task.type}, tile ${task.id}, finish after ${task.stats.calls} calls, ${task.total_elapsed.toFixed(2)} elapsed`);
this.remove(task);
task.resolve(value);
return task.promise;
},
cancel(task) {
let val;
if (task.cancel instanceof Function) {
val = task.cancel(task); // optional cancel function
}
task.resolve(val);
},
shouldContinue(task) {
// Suspend task if it runs over its specific per-frame limit, or the global limit
task.elapsed = performance.now() - task.start_time;
this.elapsed = performance.now() - this.start_time;
return task.elapsed < task.max_time && this.elapsed < Task.max_time;
},
removeForTile(tile_id) {
for (let idx = this.queue.length - 1; idx >= 0; idx--) {
if (this.queue[idx].tile_id === tile_id) {
// log('trace', `Task: remove tasks for tile ${tile_id}`);
this.cancel(this.queue[idx]);
this.queue.splice(idx, 1);
}
}
},
setState(state) {
this.state = state;
}
};
function subscribeMixin(target) {
let listeners = [];
return Object.assign(target, {
subscribe(listener) {
if (listeners.indexOf(listener) === -1) {
listeners.push(listener);
}
},
unsubscribe(listener) {
let index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
},
unsubscribeAll() {
listeners = [];
},
trigger(event, ...data) {
listeners.forEach(listener => {
if (typeof listener[event] === 'function') {
try {
listener[event](...data);
} catch (e) {
log('warn', `Caught exception in listener for event '${event}':`, e);
}
}
});
},
hasSubscribersFor(event) {
let has = false;
listeners.forEach(listener => {
if (typeof listener[event] === 'function') {
has = true;
}
});
return has;
}
});
}
function sliceObject(obj, keys) {
let sliced = {};
keys.forEach(k => sliced[k] = obj[k]);
return sliced;
}
// Texture management
// GL texture wrapper object for keeping track of a global set of textures, keyed by a unique user-defined name
class Texture {
constructor(gl, name, options = {}) {
options = Texture.sliceOptions(options); // exclude any non-texture-specific props
this.gl = gl;
this.texture = gl.createTexture();
if (this.texture) {
this.valid = true;
}
this.bind();
this.name = name;
this.retain_count = 0;
this.config_type = null;
this.loading = null; // a Promise object to track the loading state of this texture
this.loaded = false; // successfully loaded as expected
this.filtering = options.filtering;
this.density = options.density || 1; // native pixel density of texture
this.sprites = options.sprites;
this.texcoords = {}; // sprite UVs ([0, 1] range)
this.sizes = {}; // sprite sizes (pixel size)
this.css_sizes = {}; // sprite sizes, adjusted for native texture pixel density
this.aspects = {}; // sprite aspect ratios
// Default to a 1-pixel transparent black texture so we can safely render while we wait for an image to load
// See: http://stackoverflow.com/questions/19722247/webgl-wait-for-texture-to-load
this.setData(1, 1, new Uint8Array([0, 0, 0, 0]), {
filtering: 'nearest'
});
this.loaded = false; // don't consider loaded when only placeholder data is present
// Destroy previous texture if present
if (Texture.textures[this.name]) {
// Preserve previous retain count
this.retain_count = Texture.textures[this.name].retain_count;
Texture.textures[this.name].retain_count = 0; // allow to be freed
Texture.textures[this.name].destroy();
}
// Cache texture instance and definition
Texture.textures[this.name] = this;
Texture.texture_configs[this.name] = JSON.stringify(Object.assign({
name
}, options));
this.load(options);
log('trace', `creating Texture ${this.name}`);
}
// Destroy a single texture instance
destroy({
force
} = {}) {
if (this.retain_count > 0 && !force) {
log('error', `Texture '${this.name}': destroying texture with retain count of '${this.retain_count}'`);
return;
}
if (!this.valid) {
return;
}
this.gl.deleteTexture(this.texture);
this.texture = null;
if (Texture.textures[this.name] === this) {
delete Texture.textures[this.name];
delete Texture.texture_configs[this.name];
}
this.valid = false;
log('trace', `destroying Texture ${this.name}`);
}
retain() {
this.retain_count++;
}
release() {
if (this.retain_count <= 0) {
log('error', `Texture '${this.name}': releasing texture with retain count of '${this.retain_count}'`);
}
this.retain_count--;
if (this.retain_count <= 0) {
this.destroy();
}
}
bind(unit = 0) {
if (!this.valid) {
return;
}
if (Texture.activeUnit !== unit) {
this.gl.activeTexture(this.gl.TEXTURE0 + unit);
Texture.activeUnit = unit;
Texture.boundTexture = null; // texture must be re-bound when unit changes
}
if (Texture.boundTexture !== this.texture) {
this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
Texture.boundTexture = this.texture;
}
}
load(options) {
if (!options) {
return this.loading || Promise.resolve(this);
}
this.loading = null;
if (typeof options.url === 'string') {
this.config_type = 'url';
this.setUrl(options.url, options);
} else if (options.element) {
this.config_type = 'element';
this.setElement(options.element, options);
} else if (options.data && options.width && options.height) {
this.config_type = 'data';
this.setData(options.width, options.height, options.data, options);
}
this.loading = this.loading && this.loading.then(() => {
this.calculateSprites();
return this;
}) || Promise.resolve(this);
return this.loading;
}
// Sets texture from an url
setUrl(url, options = {}) {
if (!this.valid) {
return;
}
this.url = url; // save URL reference (will be overwritten when element is loaded below)
this.loading = new Promise(resolve => {
let image = new Image();
image.onload = () => {
try {
// For data URL images, first draw the image to a separate canvas element. Workaround for
// obscure bug seen with small (<28px) SVG images encoded as data URLs in Chrome and Safari.
if (this.url.slice(0, 5) === 'data:') {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
this.setElement(canvas, options);
} else {
this.setElement(image, options);
}
} catch (e) {
this.loaded = false;
log('warn', `Texture '${this.name}': failed to load url: '${this.url}'`, e, options);
Texture.trigger('warning', {
message: `Failed to load texture from ${this.url}`,
error: e,
texture: options
});
}
this.loaded = true;
resolve(this);
};
image.onerror = e => {
// Warn and resolve on error
this.loaded = false;
log('warn', `Texture '${this.name}': failed to load url: '${this.url}'`, e, options);
Texture.trigger('warning', {
message: `Failed to load texture from ${this.url}`,
error: e,
texture: options
});
resolve(this);
};
// 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() && this.url.slice(0, 5) === 'data:')) {
image.crossOrigin = 'anonymous';
}
image.src = this.url;
});
return this.loading;
}
// Sets texture to a raw image buffer
setData(width, height, data, options = {}) {
this.width = width;
this.height = height;
// Convert regular array to typed array
if (Array.isArray(data)) {
data = new Uint8Array(data);
}
this.update(data, options);
this.setFiltering(options);
this.loaded = true;
this.loading = Promise.resolve(this);
return this.loading;
}
// Sets the texture to track a element (canvas/image)
setElement(element, options) {
let el = element;
// a string element is interpeted as a CSS selector
if (typeof element === 'string') {
element = document.querySelector(element);
}
if (element instanceof HTMLCanvasElement || element instanceof HTMLImageElement || element instanceof HTMLVideoElement) {
this.update(element, options);
this.setFiltering(options);
} else {
this.loaded = false;
let msg = `the 'element' parameter (\`element: ${JSON.stringify(el)}\`) must be a CSS `;
msg += 'selector string, or a <canvas>, <image> or <video> object';
log('warn', `Texture '${this.name}': ${msg}`, options);
Texture.trigger('warning', {
message: `Failed to load texture because ${msg}`,
texture: options
});
}
this.loaded = true;
this.loading = Promise.resolve(this);
return this.loading;
}
// Uploads current image or buffer to the GPU (can be used to update animated textures on the fly)
update(source, options = {}) {
if (!this.valid) {
return;
}
this.bind();
// Image or Canvas element
if (source instanceof HTMLCanvasElement || source instanceof HTMLVideoElement || source instanceof HTMLImageElement && source.complete) {
this.width = source.width;
this.height = source.height;
this.gl.pixelStorei(this.gl.UNPACK_FLIP_Y_WEBGL, options.UNPACK_FLIP_Y_WEBGL === false ? false : true);
this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, options.UNPACK_PREMULTIPLY_ALPHA_WEBGL || false);
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, source);
}
// Raw image buffer
else {
// these pixel store params are deprecated for non-DOM element uploads
// (e.g. when creating texture from raw data)
// setting them to null avoids a Firefox warning
this.gl.pixelStorei(this.gl.UNPACK_FLIP_Y_WEBGL, null);
this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, null);
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.width, this.height, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, source);
}
Texture.trigger('update', this);
}
// Determines appropriate filtering mode
setFiltering(options = {}) {
if (!this.valid) {
return;
}
options.filtering = options.filtering || 'linear';
var gl = this.gl;
this.bind();
// For power-of-2 textures, the following presets are available:
// mipmap: linear blend from nearest mip
// linear: linear blend from original image (no mips)
// nearest: nearest pixel from original image (no mips, 'blocky' look)
if (Utils.isPowerOf2(this.width) && Utils.isPowerOf2(this.height)) {
this.power_of_2 = true;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, options.TEXTURE_WRAP_S || options.repeat && gl.REPEAT || gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, options.TEXTURE_WRAP_T || options.repeat && gl.REPEAT || gl.CLAMP_TO_EDGE);
if (options.filtering === 'mipmap') {
this.filtering = 'mipmap';
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); // TODO: use trilinear filtering by defualt instead?
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.generateMipmap(gl.TEXTURE_2D);
} else if (options.filtering === 'linear') {
this.filtering = 'linear';
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
} else if (options.filtering === 'nearest') {
this.filtering = 'nearest';
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}
} else {
// WebGL has strict requirements on non-power-of-2 textures:
// No mipmaps and must clamp to edge
this.power_of_2 = false;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (options.filtering === 'nearest') {
this.filtering = 'nearest';
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
} else {
// default to linear for non-power-of-2 textures
this.filtering = 'linear';
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
}
}
Texture.trigger('update', this);
}
// Pre-calc sprite regions for a texture sprite in UV [0, 1] space
calculateSprites() {
if (this.sprites) {
for (let s in this.sprites) {
let sprite = this.sprites[s];
// Map [0, 0] to [1, 1] coords to the appropriate sprite sub-area of the texture
this.texcoords[s] = Texture.getTexcoordsForSprite([sprite[0], sprite[1]], [sprite[2], sprite[3]], [this.width, this.height]);
// Pixel size of sprite
// Divide by native texture density to get correct CSS pixels
this.sizes[s] = [sprite[2], sprite[3]];
this.css_sizes[s] = [sprite[2] / this.density, sprite[3] / this.density];
this.aspects[s] = sprite[2] / sprite[3];
}
}
}
// Get the tetxure size in bytes
byteSize() {
// mipmaps use 33% additional memory
return Math.round(this.width * this.height * 4 * (this.filtering == 'mipmap' ? 1.33 : 1));
}
}
// Static/class methods and state
Texture.create = function (gl, name, options) {
return new Texture(gl, name, options);
};
Texture.retain = function (name) {
if (Texture.textures[name]) {
Texture.textures[name].retain();
}
};
Texture.release = function (name) {
if (Texture.textures[name]) {
Texture.textures[name].release();
}
};
// Destroy all texture instances for a given GL context
Texture.destroy = function (gl) {
var textures = Object.keys(Texture.textures);
textures.forEach(t => {
var texture = Texture.textures[t];
if (texture.gl === gl) {
texture.destroy({
force: true
});
}
});
};
// Get sprite pixel size and UVs
Texture.getSpriteInfo = function (texname, sprite) {
let texture = Texture.textures[texname];
return texture && {
size: texture.sizes[sprite],
css_size: texture.css_sizes[sprite],
aspect: texture.aspects[sprite],
texcoords: texture.texcoords[sprite]
};
};
// Re-scale UVs from [0, 1] range to a smaller area within the image
Texture.getTexcoordsForSprite = function (area_origin, area_size, tex_size) {
var area_origin_y = tex_size[1] - area_origin[1] - area_size[1];
return [area_origin[0] / tex_size[0], area_origin_y / tex_size[1], (area_size[0] + area_origin[0]) / tex_size[0], (area_size[1] + area_origin_y) / tex_size[1]];