diff --git a/Build/.DS_Store b/Build/.DS_Store new file mode 100644 index 0000000..c328838 Binary files /dev/null and b/Build/.DS_Store differ diff --git a/Build/assets/css/style.css b/Build/assets/css/style.css new file mode 100644 index 0000000..0a6ca60 --- /dev/null +++ b/Build/assets/css/style.css @@ -0,0 +1,67 @@ +body, html, #Stage { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; + -ms-content-zooming: none; + -ms-touch-action: none; + touch-action: none; +} + +.ios, .ios body, .ios #Stage { + overflow: visible; +} +.ios, .mob { + position: relative; + height: 100vh; +} + +.GLA11y { + position: absolute; + width: 0; + height: 100%; + clip: rect(0 0 0 0); + overflow: hidden; +} + +#Stage, #Stage * { + position: absolute; + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + -o-user-select: none; + -ms-user-select: none; + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-text-size-adjust: none; +} + +#Stage * input, #Stage * textarea { + -moz-user-select: auto; + -webkit-user-select: auto; + -o-user-select: auto; + -ms-user-select: auto; + -webkit-tap-highlight-color: auto; +} + +#Stage br, #Stage span { + position: relative; +} + +.feature-detects { + visibility: hidden; + pointer-events: none; + position: absolute; + width: 0; + height: 100vh; + clip: rect(0 0 0 0); +} + +@supports (--css: variables) and (padding: env(safe-area-inset-bottom)) { + .feature-detects { + --safe-area-inset-top: env(safe-area-inset-top); + --safe-area-inset-right: env(safe-area-inset-right); + --safe-area-inset-bottom: env(safe-area-inset-bottom); + --safe-area-inset-left: env(safe-area-inset-left); + } +} diff --git a/Build/assets/data/uil.1675992195953.json b/Build/assets/data/uil.1675992195953.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/Build/assets/data/uil.1675992195953.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/Build/assets/data/uil.json b/Build/assets/data/uil.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/Build/assets/data/uil.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/Build/assets/images/_scenelayout/black.jpg b/Build/assets/images/_scenelayout/black.jpg new file mode 100755 index 0000000..95b7e3a Binary files /dev/null and b/Build/assets/images/_scenelayout/black.jpg differ diff --git a/Build/assets/images/_scenelayout/mask.jpg b/Build/assets/images/_scenelayout/mask.jpg new file mode 100755 index 0000000..b997bd0 Binary files /dev/null and b/Build/assets/images/_scenelayout/mask.jpg differ diff --git a/Build/assets/images/_scenelayout/uv.jpg b/Build/assets/images/_scenelayout/uv.jpg new file mode 100755 index 0000000..43da1da Binary files /dev/null and b/Build/assets/images/_scenelayout/uv.jpg differ diff --git a/Build/assets/js/app--debug.1675992195953.js b/Build/assets/js/app--debug.1675992195953.js new file mode 100644 index 0000000..398dfa0 --- /dev/null +++ b/Build/assets/js/app--debug.1675992195953.js @@ -0,0 +1,64554 @@ +// -------------------------------------- +// +// _ _ _/ . _ _/ /_ _ _ _ +// /_|/_ / /|//_ / / //_ /_// /_/ +// https://activetheory.net _/ +// +// -------------------------------------- +// 2/9/23 6:23p +// -------------------------------------- + +/** + * Native polyfills and extensions for Hydra + * @name Polyfill + */ + +if (typeof(console) === 'undefined') { + window.console = {}; + console.log = console.error = console.info = console.debug = console.warn = console.trace = function() {}; +} + +window.performance = (function() { + if (window.performance && window.performance.now) return window.performance; + else return Date; +})(); + +Date.now = Date.now || function() { return +new Date; }; + +if (!window.requestAnimationFrame) { + window.requestAnimationFrame = (function() { + return window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + (function() { + const start = Date.now(); + return function(callback) { + window.setTimeout(() => callback(Date.now() - start), 1000 / 60); + } + })(); + })(); +} + +/** + * Temporary alias for Core. Gets overwritten when Timer instantiated. + * @see Timer + * @private + */ +window.defer = window.requestAnimationFrame; + +/** + * Extends clearTimeout to clear hydra timers as well as native setTimeouts + * @name window.clearTimeout + * @memberof Polyfill + * + * @function + * @param {Number} ref + * @example + * let timer = _this.delayedCall(myFunc, 1000); + * clearTimeout(timer); + */ +window.clearTimeout = (function() { + const _clearTimeout = window.clearTimeout; + return function(ref) { + + // If Timer exists, try and see if is a hydra timer ref otherwise run native + if (window.Timer) return Timer.__clearTimeout(ref) || _clearTimeout(ref); + return _clearTimeout(ref); + } +})(); + +/** + * Fires callback when framerate idles, else fire at max time. Alias of window.requestIdleCallback + * @name window.onIdle + * @memberof Polyfill + * + * @function + * @param {Function} callback + * @param {Number} max - Milliseconds + * @example + * onIdle(myFunc, 1000); + */ +window.requestIdleCallback = (function() { + const _requestIdleCallback = window.requestIdleCallback; + return function(callback, max) { + if (_requestIdleCallback) { + return _requestIdleCallback(callback, max ? {timeout: max} : null); + } + return defer(() => { + callback({didTimeout: false}); + }, 0); + } +})(); + +window.onIdle = window.requestIdleCallback; + +if (typeof Float32Array == 'undefined') Float32Array = Array; + +/** + * @name Math.sign + * @memberof Polyfill + * + * @function + * @param {Number} x + * @return {Number} Returns 1.0 if above 0.0, or -1.0 if below + */ +Math.sign = function(x) { + x = +x; // convert to a number + if (x === 0 || isNaN(x)) return Number(x); + return x > 0 ? 1 : -1; +}; + +/** + * Returns rounded number, with decimal places equal to precision + * @name Math.round + * @memberof Polyfill + * + * @function + * @param {Number} Value to be rounded + * @param {Integer} [precision = 0] Number of decimal places to return. 0 for integers. + * @returns {Number} Rounded number + * @example + * // Returns 3.14 + * Math.round(3.14854839, 2); + */ +Math._round = Math.round; +Math.round = function(value, precision = 0) { + let p = Math.pow(10, precision); + return Math._round(value * p) / p; +}; + +/** + * Returns random number between min and max values inclusive, with decimal places equal to precision + * @name Math.random + * @memberof Polyfill + * + * @function + * @param {Number} [min=0] Min possible returned value + * @param {Number} [max=1] Max possible returned value - inclusive. + * @param {Integer} [precision = 0] Number of decimal places to return. 0 for integers. + * @returns {Number} Between min and max inclusive + * @example + * // Returns int between 3 and 5 inclusive + * Math.random(3, 5, 0); + */ +Math._random = Math.random; +Math.rand = Math.random = function(min, max, precision = 0) { + if (typeof min === 'undefined') return Math._random(); + if (min === max) return min; + + min = min || 0; + max = max || 1; + + if (precision == 0) return Math.floor(Math._random() * ((max+1) - min) + min); + return Math.round((min + Math._random() * (max - min)), precision); +}; + +/** + * Converts radians into degrees + * @name Math.degrees + * @memberof Polyfill + * + * @function + * @param {Number} radians + * @returns {Number} + */ + +Math.degrees = function(radians) { + return radians * (180 / Math.PI); +}; + +/** + * Converts degrees into radians + * @name Math.radians + * @memberof Polyfill + * + * @function + * @param {Number} degrees + * @returns {Number} + */ +Math.radians = function(degrees) { + return degrees * (Math.PI / 180); +}; + +/** + * Clamps value between min and max + * @name Math.clamp + * @memberof Polyfill + * + * @function + * @param {Number} value + * @param {Number} [min = 0] + * @param {Number} [max = 1] + * @returns {Number} + */ +Math.clamp = function(value, min = 0, max = 1) { + return Math.min(Math.max(value, Math.min(min, max)), Math.max(min, max)); +}; + +/** + * Maps value from an old range onto a new range + * @name Math.map + * @memberof Polyfill + * + * @function + * @param {Number} value + * @param {Number} [oldMin = -1] + * @param {Number} [oldMax = 1] + * @param {Number} [newMin = 0] + * @param {Number} [newMax = 1] + * @param {Boolean} [isClamp = false] + * @returns {Number} + * @example + * // Convert sine curve's -1.0 > 1.0 value to 0.0 > 1.0 range + * let x = Math.map(Math.sin(time)); + * @example + * // Shift range + * let y = 80; + * let x = Math.map(y, 0, 200, -10, 10); + * console.log(x); // logs -2 + * @example + * // Reverse direction and shift range + * let y = 0.9; + * let x = Math.map(y, 0, 1, 200, 100); + * console.log(x); // logs 110 + */ +Math.map = Math.range = function(value, oldMin = -1, oldMax = 1, newMin = 0, newMax = 1, isClamp) { + const newValue = (((value - oldMin) * (newMax - newMin)) / (oldMax - oldMin)) + newMin; + if (isClamp) return Math.clamp(newValue, Math.min(newMin, newMax), Math.max(newMin, newMax)); + return newValue; +}; + +/** + * Return blend between two values based on alpha paramater + * @name Math.mix + * @memberof Polyfill + * + * @function + * @param {Number} a + * @param {Number} b + * @param {Number} alpha - Range of 0.0 to 1.0. Value of 0.0 returns a, value of 1.0 returns b + * @returns {Number} + * @example + * console.log(Math.mix(0, 10, 0.4)); // logs 4 + */ +Math.mix = function(a, b, alpha) { + return a * (1.0 - alpha) + b * alpha; +}; + +/** + * Returns 0.0 if value less than edge, 1.0 if greater. + * @name Math.step + * @memberof Polyfill + * + * @function + * @param {Number} edge + * @param {Number} value + * @returns {Number} + */ +Math.step = function(edge, value) { + return (value < edge) ? 0 : 1; +}; + +/** + * Returns 0.0 if value less than min, 1.0 if greater than max. Otherwise the return value is interpolated between 0.0 and 1.0 using Hermite polynomials. + * @name Math.smoothstep + * @memberof Polyfill + * + * @function + * @param {Number} min + * @param {Number} max + * @param {Number} value + * @returns {Number} + */ +Math.smoothStep = function(min, max, value) { + const x = Math.max(0, Math.min(1, (value - min) / (max - min))); + return x * x * (3 - 2 * x); +}; + +/** + * Returns fraction part of value + * @name Math.fract + * @memberof Polyfill + * + * @function + * @param {Number} value + * @returns {Number} + */ +Math.fract = function(value) { + return value - Math.floor(value); +}; + +/** + * Returns time-based interpolated value + * @name Math.lerp + * @memberof Polyfill + * + * @function + * @param {Number} target + * @param {Number} value + * @param {Number} alpha + * @returns {Number} + */ +Math.lerp = function (target, value, alpha, calcHz = true) { + if (calcHz) { + alpha = Math.framerateNormalizeLerpAlpha(alpha); + } else { + alpha = Math.clamp(alpha); + } + return value + ((target - value) * alpha); +}; + +{ + const mainThread = !!window.document; + + /** + * Returns logarithmically normalized value of a lerp alpha based on exact time + * since last frame, where the alpha is given based on the “standard” refresh + * rate 60Hz. Accounts for dropped frames and faster or slower refresh rates. + * + * To see why the alpha does not vary linearly with respect to the refresh + * rate, consider an example lerping from 0 to 1 with alpha 0.5. + * + * Lerping at 60Hz: + * frame 1.0: 0 * 0.5 + 1 * 0.5 = 0.5 + * frame 2.0: 0.5 * 0.5 + 1 * 0.5 = 0.75 + * frame 3.0: 0.75 * 0.5 + 1 * 0.5 = 0.875 + * ... + * + * When lerping at 120Hz, the lerp should proceed at the same rate as for + * the 60Hz example, so the values at frames 1.0, 2.0 etc. should be + * the same as above. But if we simply halve the alpha to 0.25, we get: + * frame 0.5: 0 * 0.75 + 1 * 0.25 = 0.25 + * frame 1.0: 0.25 * 0.75 + 1 * 0.25 = 0.4375 + * frame 1.5: 0.4375 * 0.75 + 1 * 0.25 = 0.578125 + * frame 2.0: 0.578125 * 0.75 + 1 * 0.25 = 0.68359375 + * frame 2.5: 0.68359375 * 0.75 + 1 * 0.25 = 0.7626953125 + * frame 3.0: 0.7626953125 * 0.75 + 1 * 0.25 = 0.822021484375 + * - i.e. the lerp is progressing more slowly + * + * The correct alpha is ~0.293: + * frame 0.5: 0 * 0.707 + 1 * 0.293 = 0.293 + * frame 1.0: 0.293 * 0.707 + 1 * 0.293 = ~0.5 + * frame 1.5: 0.5 * 0.707 + 1 * 0.293 = 0.6465 + * frame 2.0: 0.6465 * 0.707 + 1 * 0.293 = ~0.75 + * frame 2.5: 0.75 * 0.707 + 1 * 0.293 = 0.82325 + * frame 3.0: 0.82325 * 0.707 + 1 * 0.293 = ~0.875 + * + * @param t + * @returns {number} + */ + Math.framerateNormalizeLerpAlpha = function(t) { + t = Math.clamp(t); + if (!mainThread) return t; + return 1 - Math.exp(Math.log(1 - t) * Render.FRAME_HZ_MULTIPLIER); + }; +} + +/** + * Modulo limited to positive numbers + * @name Math.mod + * @memberof Polyfill + * + * @function + * @param {Number} value + * @param {Number} n + * @returns {Number} + */ +Math.mod = function(value, n) { + return ((value % n) + n) % n; +}; + +/** + * Shuffles array + * @name Array.prototype.shuffle + * @memberof Polyfill + * + * @function + * @returns {Array} shuffled + */ + +Object.defineProperty(Array.prototype, 'shuffle', { + writable: true, + value: function() { + let currentIndex = this.length, randomIndex; + + while (currentIndex != 0) { + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + [this[currentIndex], this[randomIndex]] = [this[randomIndex], this[currentIndex]]; + } + + return this; + } +}); + +Array.storeRandom = function(arr) { + arr.randomStore = []; +}; + +/** + * Returns random element. If range passed in, will not return same element again until function has been called enough times to surpass the value. + * @name Array.prototype.random + * @memberof Polyfill + * + * @function + * @param {Integer} [range] + * @returns {ArrayElement} + * @example + * let a = [1, 2, 3, 4]; + * for (let i = 0; i < 6; i++) console.log(a.random(4)); // logs 3, 1, 2, 4, 3, 1 + */ + + Object.defineProperty(Array.prototype, 'random', { + writable: true, + value: function(range) { + let value = Math.random(0, this.length - 1); + if (arguments.length && !this.randomStore) Array.storeRandom(this); + if (!this.randomStore) return this[value]; + if (range > this.length - 1) range = this.length; + if (range > 1) { + while (!!~this.randomStore.indexOf(value)) if ((value += 1) > this.length - 1) value = 0; + this.randomStore.push(value); + if (this.randomStore.length >= range) this.randomStore.shift(); + } + return this[value]; + } + }); + +/** + * Finds and removes element value from array + * @name Array.prototype.remove + * @memberof Polyfill + * + * @function + * @param {ArrayElement} element - Element to remove + * @returns {Array} Array containing removed element + * @example + * let a = ['cat', 'dog']; + * a.remove('cat'); + * console.log(a); // logs ['dog'] + */ + +Object.defineProperty(Array.prototype, 'remove', { + writable: true, + value: function(element) { + if (!this.indexOf) return; + const index = this.indexOf(element); + if (!!~index) return this.splice(index, 1); + } +}); + +/** + * Returns last element + * @name Array.prototype.last + * @memberof Polyfill + * + * @function + * @returns {ArrayElement} + */ + +Object.defineProperty(Array.prototype, 'last', { + writable: true, + value: function() { + return this[this.length - 1] + } +}); + +window.Promise = window.Promise || {}; + +if (!Array.prototype.flat) { + Object.defineProperty(Array.prototype, 'flat', { + configurable: true, + value: function flat () { + var depth = isNaN(arguments[0]) ? 1 : Number(arguments[0]); + + return depth ? Array.prototype.reduce.call(this, function (acc, cur) { + if (Array.isArray(cur)) { + acc.push.apply(acc, flat.call(cur, depth - 1)); + } else { + acc.push(cur); + } + + return acc; + }, []) : Array.prototype.slice.call(this); + }, + writable: true + }); +} + +/** + * Returns new Promise object + * @name Promise.create + * @memberof Polyfill + * + * @function + * @returns {Promise} + * @example + * function waitOneSecond() { + * let p = Promise.create(); + * _this.delayedCall(p.resolve, 1000); + * return p + * } + * waitOneSecond().then(() => console.log('happy days')); + */ +Promise.create = function() { + const promise = new Promise((resolve, reject) => { + this.temp_resolve = resolve; + this.temp_reject = reject; + }); + promise.resolve = this.temp_resolve; + promise.reject = this.temp_reject; + delete this.temp_resolve; + delete this.temp_reject; + return promise; +}; + +Promise.catchAll = function(array) { + return Promise.all(array.map(promise => + promise.catch(error => { + // Now that the rejection is handled, the original promise will + // complete with result undefined, so Promise.all() will complete. + // To allow the rejection to still be handled by a global + // `unhandledrejection` handler (e.g. so that Dev.postErrorsToServer() + // can log it), reject a new separate promise. + // (Note: For this to work, the new promise must not be returned here). + Promise.reject(error); + }) + )); +}; + +Promise.timeout = function(promise, timeout) { + if (Array.isArray(promise)) { + promise = Promise.all(promise); + } + return Promise.race([promise, Timer.delayedCall(timeout)]); +}; + +(function() { + // `IsRegExp` abstract operation + // https://tc39.es/ecma262/#sec-isregexp + function isRegExp(it) { + var isObject = typeof it == 'object' ? it !== null : (typeof it == 'function'); + if (!isObject) return false; + var match = it[typeof Symbol !== 'undefined' ? Symbol.match : 'match']; + if (match !== undefined) return !!match; + return Object.prototype.toString.call(it).slice(8, -1) === 'RegExp'; + } + + function notRegExp(it) { + if (isRegExp(it)) throw new Error('First argument to String.prototype.includes must not be a regular expression'); + return it; + } + + /** + * Check if string contains phrase + * @name String.prototype.includes + * @memberof Polyfill + * + * @function + * @param {String|String[]} str - Either a string or array of strings to check for + * @returns {boolean} + * @example + * let userName = 'roger moore'; + * console.log(userName.includes(['steve', 'andrew', 'roger']); // logs true + */ + Object.defineProperty(String.prototype, 'includes', { + writable: true, + value: function(str) { + if (!Array.isArray(str)) return !!~this.indexOf(notRegExp(str)); + for (let i = str.length - 1; i >= 0; i--) { + if (!!~this.indexOf(notRegExp(str[i]))) return true; + } + return false; + } + }); + +})(); + +Object.defineProperty(String.prototype, 'equals', { + writable: true, + value: function(str) { + let compare = String(this); + if (!Array.isArray(str)) return str === compare; + for (let i = str.length - 1; i >= 0; i--) { + if (str[i] === compare) return true; + } + return false; + } +}); + +Object.defineProperty(String.prototype, 'strpos', { + writable: true, + value: function(str) { + console.warn('strpos deprecated: use .includes()'); + return this.includes(str); + } +}); + +/** + * Returns clipped string. Doesn't alter original string. + * @name String.prototype.clip + * @memberof Polyfill + * + * @function + * @param {Number} num - character length to clip to + * @param {String} [end] - add string to end, such as elipsis '...' + * @returns {string} - clipped string + */ + Object.defineProperty(String.prototype, 'clip', { + writable: true, + value: function(num, end = '') { + return this.length > num ? this.slice(0, Math.max( 0, num - end.length )).trim() + end : this.slice(); + } + }); + +/** + * Returns string with uppercase first letter. Doesn't alter original string. + * @name String.prototype.capitalize + * @memberof Polyfill + * + * @function + * @returns {string} + */ + Object.defineProperty(String.prototype, 'capitalize', { + writable: true, + value: function() { + return this.charAt(0).toUpperCase() + this.slice(1); + } + }); + +/** + * Replaces all occurrences within a string + * @name String.prototype.replaceAll + * @memberof Polyfill + * + * @function + * @param {String} find - sub string to be replaced + * @param {String} replace - sub string that replaces all occurrences + * @returns {string} + */ + Object.defineProperty(String.prototype, 'replaceAll', { + writable: true, + value: function(find, replace) { + return this.split(find).join(replace); + } + }); + +Object.defineProperty(String.prototype, 'replaceAt', { + writable: true, + value: function(index, replacement) { + return this.substr(0, index) + replacement + this.substr(index + replacement.length); + } +}); + +/** + * fetch API polyfill + * @private + */ +if (!window.fetch || (!window.AURA && location.protocol.includes('file'))) window.fetch = function(url, options) { + options = options || {}; + const promise = Promise.create(); + const request = new XMLHttpRequest(); + + request.open(options.method || 'get', url); + if (url.includes('.ktx')) request.responseType = 'arraybuffer'; + + for (let i in options.headers) { + request.setRequestHeader(i, options.headers[i]); + } + + // request.withCredentials = options.credentials == 'include'; + + request.onload = () => { + promise.resolve(response()); + }; + + request.onerror = promise.reject; + + request.send(options.body); + + function response() { + let keys = [], + all = [], + headers = {}, + header; + + request.getAllResponseHeaders().replace(/^(.*?):\s*([\s\S]*?)$/gm, (m, key, value) => { + keys.push(key = key.toLowerCase()); + all.push([key, value]); + header = headers[key]; + headers[key] = header ? `${header},${value}` : value; + }); + + return { + ok: (request.status/200|0) == 1, // 200-399 + status: request.status, + statusText: request.statusText, + url: request.responseURL, + clone: response, + + text: () => Promise.resolve(request.responseText), + json: () => Promise.resolve(request.responseText).then(JSON.parse), + xml: () => Promise.resolve(request.responseXML), + blob: () => Promise.resolve(new Blob([request.response])), + arrayBuffer: () => Promise.resolve(request.response), + + headers: { + keys: () => keys, + entries: () => all, + get: n => headers[n.toLowerCase()], + has: n => n.toLowerCase() in headers + } + }; + } + return promise; +}; + +/** + * Send http GET request. Wrapper around native fetch api. Automatically parses json. + * @name window.get + * @memberof Polyfill + * + * @function + * @param {String} url + * @param {Object} options + * @returns {Promise} + * @example + * get('assets/geometry/curves.json).then(d => console.log(d)); + */ +window.get = function(url, options = {credentials: 'same-origin'}) { + let promise = Promise.create(); + options.method = 'GET'; + + fetch(url, options).then(handleResponse).catch(promise.reject); + + function handleResponse(e) { + if (!e.ok) return promise.reject(e); + e.text().then(text => { + if (text.charAt(0).includes(['[', '{'])) { + + // Try to parse json, else return text + try { + promise.resolve(JSON.parse(text)); + } catch (err) { + promise.resolve(text); + } + } else { + promise.resolve(text); + } + }); + } + + return promise; +}; + +/** + * Send http POST request. Wrapper around native fetch api. + * @name window.post + * @memberof Polyfill + * + * @function + * @param {String} url + * @param {Object} body + * @param {Object} [options] + * @returns {Promise} + */ +window.post = function(url, body = {}, options = {}) { + let promise = Promise.create(); + options.method = 'POST'; + if (body) options.body = typeof body === 'object' || Array.isArray(body) ? JSON.stringify(body) : body; + if (!options.headers) options.headers = {'content-type': 'application/json'}; + + fetch(url, options).then(handleResponse).catch(promise.reject); + + function handleResponse(e) { + if (!e.ok) return promise.reject(e); + e.text().then(text => { + if (text.charAt(0).includes('[') || text.charAt(0).includes('{')) { + + // Try to parse json, else return text + try { + promise.resolve(JSON.parse(text)); + } catch (err) { + promise.resolve(text); + } + } else { + promise.resolve(text); + } + }); + } + + return promise; +}; + +/** + * Send http PUT request. Wrapper around native fetch api. + * @name window.put + * @memberof Polyfill + * + * @function + * @param {String} url + * @param {Object} body + * @param {Object} [options] + * @returns {Promise} + */ +window.put = function(url, body, options = {}) { + let promise = Promise.create(); + options.method = 'PUT'; + if (body) options.body = typeof body === 'object' || Array.isArray(body) ? JSON.stringify(body) : body; + + fetch(url, options).then(handleResponse).catch(promise.reject); + + function handleResponse(e) { + if (!e.ok) return promise.reject(e); + e.text().then(text => { + if (text.charAt(0).includes(['[', '{'])) { + + // Try to parse json, else return text + try { + promise.resolve(JSON.parse(text)); + } catch (err) { + promise.resolve(text); + } + } else { + promise.resolve(text); + } + }); + } + + return promise; +}; + +/** + * Class creation and stucture. + * @name Core + */ + +/** + * Class constructor + * @name Class + * @memberof Core + * + * @function + * @param {Function} _class - main class function + * @param {String|Function} [_type] - class type ('static' or 'singleton') or static function + * @param {Function} [_static] - static function if type is passed through, useful for 'singleton' type + * @example + * + * // Instance + * Class(function Name() { + * //... + * }); + * + * new Name(); // or + * _this.initClass(Name); + * @example + * // Static + * Class(function Name() { + * //... + * }, 'static'); + * + * console.log(Name); + * @example + * // Singleton + * Class(function Name() { + * //... + * }, 'singleton'); + * + * Name.instance(); + * @example + * // Instance with Static function + * Class(function Name() { + * //... + * }, function() { + * // Static + * Name.EVENT_NAME = 'event_name'; + * }); + * @example + * // Singleton with Static function + * Class(function Name() { + * //... + * }, 'singleton', function() { + * // Static + * }); + + */ +window.Class = function(_class, _type, _static) { + const _this = this || window; + + // Function.name ie12+ only + const _name = _class.name || _class.toString().match(/function ?([^\(]+)/)[1]; + + // if (typeof Hydra !== 'undefined' && Hydra.LOCAL && _this[_name] && _this[_name].toString().indexOf('[native code]') < 0) { + // console.warn('Class ' + _name + ' already exists!'); + // } + + // Polymorphic if no type passed + if (typeof _type === 'function') { + _static = _type; + _type = null; + } + + _type = (_type || '').toLowerCase(); + + // Instanced Class + if (!_type) { + _this[_name] = _class; + + // Initiate static function if passed through + _static && _static(); + } else { + + // Static Class + if (_type == 'static') { + _this[_name] = new _class(); + + // Singleton Class + } else if (_type == 'singleton') { + _this[_name] = _class; + + (function() { + let _instance; + + _this[_name].instance = function() { + if (!_instance) _instance = new _class(...arguments); + return _instance; + }; + })(); + + // Initiate static function if passed through + _static && _static(); + } + } + + // Giving namespace classes reference to namespace + if (this && this !== window) this[_name]._namespace = this.__namespace; +}; + +/** + * Inherit class + * @name Inherit + * @memberof Core + * + * @function + * @param {Object} child + * @param {Function} parent + * @param {Array} [params] + * @example + * Class(function Parent() { + * this.method = function() { + * console.log(`I'm a Parent`); + * }; + * }); + * + * Class(function Child() { + * Inherit(this, Parent); + * + * // Call parent method + * this.method(); + * // Logs 'I'm a Parent' + * + * // Overwrite method + * this.method = function() { + * console.log(`I'm a Child`); + * + * // Call overwritten method with _ prefix + * this._method(); + * }; + * }); + * + * let child = new Child(); + * + * // Need to defer to wait for method overwrite + * defer(child.method); + * // Logs 'I'm a Child', 'I'm a Parent' + */ +window.Inherit = function(child, parent) { + const args = [].slice.call(arguments, 2); + parent.apply(child, args); + + // Store methods for super calls + const save = {}; + for (let method in child) { + save[method] = child[method]; + } + + const addSuperMethods = () => { + for (let method in save) { + if (child[method] && child[method] !== save[method]) { + if (method == 'destroy' && !child.__element) throw 'Do not override destroy directly, use onDestroy :: ' + child.constructor.toString(); + let name = method; + do { + name = `_${name}`; + } while (child[name]); + child[name] = save[method]; + } + } + }; + if (child.__afterInitClass) { + // When using Component.initClass(), use the hook to add super methods + // synchronously, immediately after the constructor returns + child.__afterInitClass.push(addSuperMethods); + } else { + // defer to wait for child to add its own methods + defer(addSuperMethods); + } +}; + +/** + * Create class namespace for hydra + * @name Namespace + * @memberof Core + * + * @function + * @param {Object|String} obj + * @example + * // Example using object + * Class(function Baby() { + * Namespace(this); + * }, 'static'); + * + * Baby.Class(function Powder() {}); + * + * new Baby.Powder(); + * @example + * // Example using string + * Class(function Baby() { + * Namespace('Talcum'); + * }, 'static'); + * + * Talcum.Class(function Powder() {}); + * + * new Talcum.Powder(); + */ +window.Namespace = function(obj) { + if (typeof obj === 'string') { + if (!window[obj]) window[obj] = {Class, __namespace: obj}; + } else { + obj.Class = Class; + obj.__namespace = obj.constructor.name || obj.constructor.toString().match(/function ([^\(]+)/)[1]; + } +}; + +/** + * Object to attach global properties + * @name window.Global + * @memberof Core + * + * @example + * Global.PLAYGROUND = true; + */ +window.Global = {}; + +/** + * Boolean for if Hydra is running on a thread + * @name window.THREAD + * @memberof Core + */ +window.THREAD = false; + +/** + * Hydra namespace. Fires ready callbacks and kicks off Main class once loaded. + * @name Hydra + */ + +Class(function Hydra() { + const _this = this; + const _readyPromise = Promise.create(); + var _base; + + var _callbacks = []; + + this.HASH = window.location.hash.slice(1); + this.LOCAL = !window._BUILT_ && (location.hostname.indexOf('local') > -1 || location.hostname.split('.')[0] == '10' || location.hostname.split('.')[0] == '192' || /atdev.online$/.test(location.hostname)) && location.port == ''; + + (function () { + initLoad(); + })(); + + function initLoad() { + if (!document || !window) return setTimeout(initLoad, 1); + if (window._NODE_) return setTimeout(loaded, 1); + + if (window._AURA_) { + if (!window.Main) return setTimeout(initLoad, 1); + else return setTimeout(loaded, 1); + } + + window.addEventListener('load', loaded, false); + } + + function loaded() { + window.removeEventListener('load', loaded, false); + + _this.LOCAL = (!window._BUILT_ || location.pathname.toLowerCase().includes('platform')) && (location.hostname.indexOf('local') > -1 || location.hostname.split('.')[0] == '10' || location.hostname.split('.')[0] == '192' || /atdev.online$/.test(location.hostname)) && location.port == ''; + + _callbacks.forEach(cb => cb()); + _callbacks = null; + + _readyPromise.resolve(); + + // Initiate app + if (window.Main) { + _readyPromise.then(() => Hydra.Main = new window.Main()); + } + } + + /** + * Trigger page load callback + * @memberof Hydra + * @private + */ + this.__triggerReady = function () { + loaded(); + }; + + /** + * Attachment for ready event + * @name Hydra.ready + * @memberof Hydra + * + * @function + * @param {Function} [callback] Function to trigger upon page load + * @returns {Promise} - Returns promise if no callback passed in + * @example + * // either + * Hydra.ready(init); + * // or + * Hydra.ready().then(init); + * function init() {} + */ + this.ready = function (callback) { + if (!callback) return _readyPromise; + if (_callbacks) _callbacks.push(callback); + else callback(); + }; + + this.absolutePath = function (path) { + if (window.AURA) return path; + let base = _base; + if (base === undefined) { + try { + if (document.getElementsByTagName('base').length > 0) { + var a = document.createElement('a'); + a.href = document.getElementsByTagName('base')[0].href; + base = a.pathname; + _base = base; + } + } catch (e) { + _base = null; + } + } + let pathname = base || location.pathname; + if (pathname.includes('/index.html')) pathname = pathname.replace('/index.html', ''); + let port = Number(location.port) > 1000 ? `:${location.port}` : ''; + return path.includes('http') ? path : (location.protocol.length ? location.protocol + '//' : '') + (location.hostname + port + pathname + '/' + path).replace('//', '/'); + } + +}, 'Static'); + +/** + * Hydra tool-belt + * @name Utils + */ + +Class(function Utils() { + + var _queries = {}; + var _searchParams = new URLSearchParams(window.location.search); + + /** + * Parse URL queries + * @name this.query + * @memberof Utils + * + * @function + * @param {String} key + * @returns {string} + * @example + * // url is myProject/HTML?dev=1 + * console.log(Utls.query('dev')); // logs '1' + * @example + * // url is myProject/HTML?dev=0 + * console.log(Utls.query('dev')); // logs false + * // Also logs false for ?dev=false or ?dev= + */ + this.query = this.queryParams = function(key, value) { + if (value !== undefined) _queries[key] = value; + + if (_queries[key] !== undefined) return _queries[key]; + + if (_searchParams) { + value = _searchParams.get(key); + if (value === '0') value = 0; + else if (value === 'false' || value === null) value = false; + else if (value === '') value = true; + } else { + let escapedKey = encodeURIComponent(key).replace(/[\.\+\*]/g, '\\$&'); + value = decodeURIComponent(window.location.search.replace(new RegExp(`^(?:.*?[&?]${escapedKey}(?:\=([^&]*)|[&$]))?.*$`, 'i'), '$1')); + if (value == '0') { + value = 0; + } else if (value == 'false') { + value = false; + } else if (!value.length) { + value = new RegExp(`[&?]${escapedKey}(?:[&=]|$)`, 'i').test(window.location.search); + } + } + _queries[key] = value; + return value; + }; + + /** + * @name this.addQuery + * @memberof Utils + * + * @function + * @param query + * @param value + */ + this.addQuery = function ( query, value ) { + if ( _queries[ query ] === value ) return _queries[ query ]; + let url = new URL(location.href); + url.searchParams.set(query, value); + _searchParams = url.searchParams; + window.history.replaceState({}, document.title, url.toString()); + return _queries[ query ] = value; + } + + /** + * @name this.removeQuery + * @memberof Utils + * + * @function + * @param query + */ + this.removeQuery = function ( query ) { + let url = new URL(location.href); + url.searchParams.delete(query); + _searchParams = url.searchParams; + window.history.replaceState({}, document.title, url.toString()); + return delete _queries[ query ]; + } + + this.addQueryToPath = function(path, hash) { + return [ + [path, _searchParams.toString()].filter(Boolean).join('?'), + hash + ].filter(Boolean).join('#'); + } + + /** + * @name this.addParam + * @memberof Utils + * + * @function + * @param url + * @param param + * @param value + */ + this.addParam = function(url, param, value) { + let index = url.indexOf('?'); + let prefix = url.substring(0, index + 1); + let suffix = url.substring(index + 1); + let searchParams = new URLSearchParams(suffix); + searchParams.append(param, value); + return prefix + searchParams.toString(); + }; + + /** + * @name this.removeParam + * @memberof Utils + * + * @function + * @param url, param + */ + this.removeParam = function(url, param) { + let index = url.indexOf('?'); + let prefix = url.substring(0, index + 1); + let suffix = url.substring(index + 1); + let searchParams = new URLSearchParams(suffix); + searchParams.delete(param); + return prefix + searchParams.toString(); + }; + + // Object utils + + /** + * Get class constructor name + * @name this.getConstructorName + * @memberof Utils + * + * @function + * @param {Object} obj + * @returns {String} + */ + this.getConstructorName = function(obj) { + if (!obj) return obj; + + if (!obj.___constructorName) { + obj.___constructorName = (function() { + if (typeof obj === 'function') return obj.toString().match(/function ([^\(]+)/)[1]; + return obj.constructor.name || obj.constructor.toString().match(/function ([^\(]+)/)[1]; + })(); + } + + return obj.___constructorName; + }; + + /** + * Nullify object's properties + * @name this.nullObject + * @memberof Utils + * + * @function + * @param {Object} object + * @returns {null} + */ + this.nullObject = function(object) { + if (object && ( object.destroy || object.div)) { + for (var key in object) { + if (typeof object[key] !== 'undefined') object[key] = null; + } + } + return null; + }; + + /** + * Clone object + * @name this.cloneObject + * @memberof Utils + * + * @function + * @param {Object} obj + * @returns {Object} + */ + this.cloneObject = function(obj) { + return JSON.parse(JSON.stringify(obj)); + }; + + /** + * Return one of two parameters randomly + * @name this.headsTails + * @memberof Utils + * + * @function + * @param {Number} n0 + * @param {Number} n1 + * @returns {Object} + */ + this.headsTails = function(n0, n1) { + return Math.random(0, 1) ? n1 : n0; + }; + + /** + * Merge objects. Takes all arguments and merges them into one object. + * @name this.mergeObject + * @memberof Utils + * + * @function + * @param {Object} Object - Any number of object paramaters + * @returns {Object} + */ + this.mergeObject = function() { + var obj = {}; + for (var i = 0; i < arguments.length; i++) { + var o = arguments[i]; + for (var key in o) { + obj[key] = o[key]; + } + } + + return obj; + }; + + // Mathematical utils + + /** + * Returns unique timestamp + * @name this.timestamp + * @memberof Utils + * + * @function + * @returns {string} + */ + this.timestamp = this.uuid = function() { + return Date.now() + 'xx-4xx-yxx-xxx'.replace(/[xy]/g, function(c) { + let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + }; + + /** + * Returns random Hex color value + * @name this.randomColor + * @memberof Utils + * + * @function + * @returns {string} - Hex color value + */ + this.randomColor = function() { + var color = '#' + Math.floor(Math.random() * 16777215).toString(16); + if (color.length < 7) color = this.randomColor(); + return color; + }; + + /** + * Turn number into comma-delimited string + * @name this.numberWithCommas + * @memberof Utils + * + * @function + * @param {Number} num + * @returns {String} - String of value with comma delimiters + */ + this.numberWithCommas = function(num) { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + }; + + /** + * Pads number with 0s to match digits amount + * @name this.padInt + * @memberof Utils + * + * @function + * @param {Number} num - Number value to convert to pad + * @param {Integer} [digits] - Number of digits to match + * @param {Boolean} [isLimit] limit to digit amount of 9s + * @returns {string} - Padded value + */ + this.padInt = function(num, digits, isLimit) { + if (isLimit) num = Math.min(num, Math.pow(10, digits) - 1); + let str = Math.floor(num).toString(); + return Math.pow(10, Math.max(0, digits - str.length)).toString().slice(1) + str; + }; + + /** + * Copies string to clipboard on interaction + * @name this.copyToClipboard + * @memberof Utils + * + * @function + * @param {string} string to copy to clipboard + * @returns {Boolean} - Success + */ + this.copyToClipboard = function(string) { + try { + var el = document.createElement( 'textarea' ); + var range = document.createRange(); + el.contentEditable = true; + el.readOnly = true; + el.value = string; + document.body.appendChild( el ); + el.select(); + range.selectNodeContents( el ); + var s = window.getSelection(); + s.removeAllRanges(); + s.addRange(range); + el.setSelectionRange(0, string.length); + document.execCommand('copy'); + document.body.removeChild( el ); + return true; + } catch ( e ) { + return false; + } + }; + + /** + * Formats an array of strings into a single string list + * @name this.stringList + * @memberof Utils + * + * @function + * @param {Array} Array of strings to join and format + * @param {Integer} Max number of items to list before shortening - optional + * @param {Object} Additional formatting options - optional + * @returns {String} - Formatted string + */ + this.stringList = function ( items = [], limit = 0, options = {} ) { + if ( items.length === 0 ) return ''; + + let output = ''; + let printed = 0; + + if ( typeof limit === 'object' ) { + options = limit; + limit = 0; + } + + options.oxford = options.oxford === true ? true : false; + options.more = options.more === false ? false : options.more ? options.more : 'more'; + options.and = options.and ? options.and : '&'; + options.comma = options.comma ? options.comma : ','; + + if ( !isNaN(options.limit)) limit = options.limit; + if ( limit === 0 ) limit = items.length; + + do { + let item = items.shift(); + output = `${output}${item}${options.comma} `; + printed++; + } while ( items.length > 1 && printed + 1 < limit ); + + output = output.trim(); + output = output.slice(0,output.length-1); + + if ( items.length === 1 ) { + output = `${output}${options.oxford && printed > 1 ? options.comma : ''} ${options.and} ${items.shift()}`; + } else if ( items.length > 1 && options.more ) { + let more = `${items.length} ${options.more}`; + output = `${output}${options.oxford && printed > 1 ? options.comma : ''} ${options.and} ${more}`; + } + + return output; + } + + /** + * @name this.debounce + * @memberof Utils + * + * @function + * @param callback + * @param time + */ + this.debounce = function (callback, time = 100) { + clearTimeout(callback.__interval); + callback.__interval = Timer.create(callback, time); + } + +}, 'Static'); + +/** + * Single global requestAnimationFrame render loop to which all other classes attach their callbacks to be triggered every frame + * @name Render + */ + +Class(function Render() { + const _this = this; + + const _render = []; + const _native = []; + const _drawFrame = []; + const _multipliers = []; + + // If loops are added or removed from within a loop, the loop counter might + // need to be adjusted to avoid missed callbacks. So put the loop counters + // in the top-level scope. + let _renderIndex = null; + let _nativeIndex = null; + + var _last = performance.now(); + var _skipLimit = 200; + var _localTSL = 0; + var _elapsed = 0; + var _capLast = 0; + var _sampleRefreshRate = []; + var _firstSample = false; + var _saveRefreshRate = 60; + var rAF = requestAnimationFrame; + var _refreshScale = 1; + var _canCap = 0; + var _screenHash = getScreenHash(); + + /** + * @name timeScaleUniform + * @memberof Render + * @property + */ + this.timeScaleUniform = {value: 1, type: 'f', ignoreUIL: true}; + /** + * @name REFRESH_TABLE + * @memberof Render + * @property + */ + this.REFRESH_TABLE = [30, 60, 72, 90, 100, 120, 144, 240]; + /** + * @name REFRESH_RATE + * @memberof Render + * @property + */ + this.REFRESH_RATE = 60; + /** + * @name HZ_MULTIPLIER + * @memberof Render + * @property + */ + this.HZ_MULTIPLIER = 1; + + /** + * @name capFPS + * @memberof Render + * @property + */ + this.capFPS = null; + + //*** Constructor + (function() { + if (THREAD) return; + rAF(render); + setInterval(_ => _sampleRefreshRate = [], 3000); + setInterval(checkMoveScreen, 5000); + })(); + + function render(tsl) { + if (_native.length) { + let multiplier = (60/_saveRefreshRate); + for (_nativeIndex = _native.length-1; _nativeIndex > -1; _nativeIndex--) { + _native[_nativeIndex](multiplier); + } + _nativeIndex = null; + } + + if (_this.capFPS > 0 && ++_canCap > 31) { + let delta = tsl - _capLast; + _capLast = tsl; + _elapsed += delta; + if (_elapsed < 1000 / _this.capFPS) return rAF(render); + _this.REFRESH_RATE = _this.capFPS; + _this.HZ_MULTIPLIER = (60/_this.REFRESH_RATE) * _refreshScale; + _elapsed = 0; + } + + _this.timeScaleUniform.value = 1; + if (_multipliers.length) { + for (let i = 0; i < _multipliers.length; i++) { + let obj = _multipliers[i]; + _this.timeScaleUniform.value *= obj.value; + } + } + + _this.DT = tsl - _last; + _last = tsl; + + let delta = _this.DT * _this.timeScaleUniform.value; + delta = Math.min(_skipLimit, delta); + + if (_sampleRefreshRate && !_this.capFPS) { + let fps = 1000 / _this.DT; + _sampleRefreshRate.push(fps); + if (_sampleRefreshRate.length > 30) { + _sampleRefreshRate.sort((a, b) => a - b); + let rate = _sampleRefreshRate[Math.round(_sampleRefreshRate.length / 2)]; + rate = _this.REFRESH_TABLE.reduce((prev, curr) => (Math.abs(curr - rate) < Math.abs(prev - rate) ? curr : prev)); + _this.REFRESH_RATE = _saveRefreshRate = _firstSample ? Math.max(_this.REFRESH_RATE, rate) : rate; + _this.HZ_MULTIPLIER = (60/_this.REFRESH_RATE) * _refreshScale; + _sampleRefreshRate = null; + _firstSample = true; + } + } + + _this.TIME = tsl; + _this.DELTA = delta; + + if (_this.startFrame) _this.startFrame(tsl, delta); + + _localTSL += delta; + + for (_renderIndex = _render.length - 1; _renderIndex >= 0; _renderIndex--) { + var callback = _render[_renderIndex]; + if (!callback) { + _render.splice(_renderIndex, 1); + continue; + } + if (callback.fps) { + if (tsl - callback.last < 1000 / callback.fps) continue; + callback(++callback.frame); + callback.last = tsl; + continue; + } + callback(tsl, delta); + } + _renderIndex = null; + + for (let i = _drawFrame.length-1; i > -1; i--) { + _drawFrame[i](tsl, delta); + } + + if (_this.drawFrame) _this.drawFrame(tsl, delta); //Deprecated + if (_this.endFrame) _this.endFrame(tsl, delta); //Deprecated + + if (!THREAD && !_this.isPaused) rAF(render); + } + + function getScreenHash() { + if (!window.screen) return 'none'; + + return `${window.screen.width}x${window.screen.height}.${window.screen.pixelDepth}`; + } + + function checkMoveScreen() { + var newScreen = getScreenHash(); + if (_screenHash === newScreen) return; + // Changed screen. recalculate refresh rate + + _screenHash = newScreen; + _sampleRefreshRate = null; + _firstSample = false; + } + + /** + * @name Render.now + * @memberof Render + * + * @function + */ + this.now = function() { + return _localTSL; + } + + /** + * @name Render.setRefreshScale + * @memberof Render + * + * @function + * @param scale + */ + this.setRefreshScale = function(scale) { + _refreshScale = scale; + _sampleRefreshRate = []; + } + + /** + * Add callback to render queue + * @name Render.start + * @memberof Render + * + * @function + * @param {Function} callback - Function to call + * @param {Integer} [fps] - Optional frames per second callback rate limit + * @example + * // Warp time using multiplier + * Render.start(loop); + * let _timewarp = 0; + * function loop(t, delta) { + * console.log(_timewarp += delta * 0.001); + * } + * @example + * // Limits callback rate to 5 + * Render.start(tick, 5); + * + * // Frame count is passed to callback instead of time information + * function tick(frame) { + * console.log(frame); + * } + */ + this.start = function(callback, fps, native) { + if (fps) { + callback.fps = fps; + callback.last = -Infinity; + callback.frame = -1; + } + + // unshift as render queue works back-to-front + if (native) { + if (!~_native.indexOf(callback)) { + _native.unshift(callback); + if (_nativeIndex !== null) _nativeIndex += 1; + } + } else { + if (!~_render.indexOf(callback)) { + _render.unshift(callback); + if (_renderIndex !== null) _renderIndex += 1; + } + } + }; + + /** + * Remove callback from render queue + * @name Render.stop + * @memberof Render + * + * @function + * @param {Function} callback + */ + this.stop = function(callback) { + let i = _render.indexOf(callback); + if (i >= 0) { + _render.splice(i, 1); + if (_renderIndex !== null && i <= _renderIndex) { + _renderIndex -= 1; + } + } + i = _native.indexOf(callback); + if (i >= 0) { + _native.splice(i, 1); + if (_nativeIndex !== null && i <= _nativeIndex) { + _nativeIndex -= 1; + } + } + }; + + /** + * Force render - for use in threads + * @name Render.tick + * @memberof Render + * + * @function + */ + this.tick = function() { + if (!THREAD) return; + this.TIME = performance.now(); + render(this.TIME); + }; + + /** + * Force render - for Vega frame by frame recording + * @name Render.tick + * @memberof Render + * + * @function + */ + this.forceRender = function(time) { + this.TIME = time; + render(this.TIME); + }; + + /** + * Distributed worker constuctor + * @name Render.Worker + * @memberof Render + + * @constructor + * @param {Function} _callback + * @param {Number} [_budget = 4] + * @example + * const worker = _this.initClass(Render.Worker, compute, 1); + * function compute() {console.log(Math.sqrt(Math.map(Math.sin(performance.now()))))}; + * _this.delayedCall(worker.stop, 1000) + * + */ + this.Worker = function(_callback, _budget = 4) { + Inherit(this, Component); + let _scope = this; + let _elapsed = 0; + this.startRender(loop); + function loop() { + if (_scope.dead) return; + while (_elapsed < _budget) { + if (_scope.dead || _scope.paused) return; + const start = performance.now(); + _callback && _callback(); + _elapsed += performance.now() - start; + } + _elapsed = 0; + } + + /** + * @name Render.stop + * @memberof Render + * + * @function + */ + this.stop = function() { + this.dead = true; + this.stopRender(loop); + //defer(_ => _scope.destroy()); + } + + /** + * @name Render.pause + * @memberof Render + * + * @function + */ + this.pause = function() { + this.paused = true; + this.stopRender(loop); + } + + /** + * @name Render.resume + * @memberof Render + * + * @function + */ + this.resume = function() { + this.paused = false; + this.startRender(loop); + } + + /** + * @name Render.setCallback + * @memberof Render + * + * @function + * @param cb + */ + this.setCallback = function(cb) { + _callback = cb; + } + }; + + /** + * Pause global render loop + * @name Render.pause + * @memberof Render + * + * @function + */ + this.pause = function() { + _this.isPaused = true; + }; + + /** + * Resume global render loop + * @name Render.resume + * @memberof Render + * + * @function + */ + this.resume = function() { + if (!_this.isPaused) return; + _this.isPaused = false; + rAF(render); + }; + + /** + * Use an alternative requestAnimationFrame function (for VR) + * @name Render.useRAF + * @param {Function} _callback + * @memberof Render + * + * @function + */ + this.useRAF = function(raf) { + _firstSample = null; + _last = performance.now(); + rAF = raf; + rAF(render); + } + + /** + * @name Render.onDrawFrame + * @memberof Render + * + * @function + * @param cb + */ + this.onDrawFrame = function(cb) { + _drawFrame.push(cb); + } + + /** + * @name Render.setTimeScale + * @memberof Render + * + * @function + * @param v + */ + this.setTimeScale = function(v) { + _this.timeScaleUniform.value = v; + } + + /** + * @name Render.getTimeScale + * @memberof Render + * + * @function + */ + this.getTimeScale = function() { + return _this.timeScaleUniform.value; + } + + /** + * @name Render.createTimeMultiplier + * @memberof Render + * + * @function + */ + /** + * @name Render.createTimeMultiplier + * @memberof Render + * + * @function + */ + this.createTimeMultiplier = function() { + let obj = {value: 1}; + _multipliers.push(obj); + return obj; + } + + /** + * @name Render.destroyTimeMultiplier + * @memberof Render + * + * @function + * @param obj + */ + this.destroyTimeMultiplier = function(obj) { + _multipliers.remove(obj); + } + + /** + * @name Render.tweenTimeScale + * @memberof Render + * + * @function + * @param value + * @param time + * @param ease + * @param delay + */ + this.tweenTimeScale = function(value, time, ease, delay) { + return tween(_this.timeScaleUniform, {value}, time, ease, delay, null, null, true); + } + + Object.defineProperty(_this, 'FRAME_HZ_MULTIPLIER', { + get() { + return (60 / (1000 / _this.DELTA)) * _refreshScale; + }, + enumerable: true + }); +}, 'Static'); + +/** + * Timer class that uses hydra Render loop, which has much less overhead than native setTimeout + * @name Timer + */ + +Class(function Timer() { + const _this = this; + const _callbacks = []; + const _discard = []; + const _deferA = []; + const _deferB = []; + var _defer = _deferA; + + (function() { + Render.start(loop); + })(); + + + function loop(t, delta) { + for (let i = _discard.length - 1; i >= 0; i--) { + let obj = _discard[i]; + obj.callback = null; + _callbacks.remove(obj); + } + if (_discard.length) _discard.length = 0; + + for (let i = _callbacks.length - 1; i >= 0; i--) { + let obj = _callbacks[i]; + if (!obj) { + _callbacks.remove(obj); + continue; + } + + if (obj.scaledTime) { + obj.current += delta; + } else { + obj.current += Render.DT; + } + + if (obj.current >= obj.time) { + obj.callback && obj.callback(); + _discard.push(obj); + } + } + + for (let i = _defer.length-1; i > -1; i--) { + _defer[i](); + } + _defer.length = 0; + _defer = _defer == _deferA ? _deferB : _deferA; + } + + function find(ref) { + for (let i = _callbacks.length - 1; i > -1; i--) if (_callbacks[i].ref == ref) return _callbacks[i]; + } + + //*** Event handlers + + //*** Public methods + + /** + * + * @private + * + * @param ref + * @returns {boolean} + */ + this.__clearTimeout = function(ref) { + const obj = find(ref); + if (!obj) return false; + obj.callback = null; + _callbacks.remove(obj); + return true; + }; + + /** + * Create timer + * @name Timer.create + * @memberof Timer + * + * @function + * @param {Function} callback + * @param {Number} time + * @returns {Number} Returns timer reference for use with window.clearTimeout + */ + this.create = function(callback, time, scaledTime) { + if (window._NODE_) return setTimeout(callback, time); + const obj = { + time: Math.max(1, time || 1), + current: 0, + ref: Utils.timestamp(), + callback, + scaledTime + }; + _callbacks.unshift(obj); + return obj.ref; + }; + + /** + * @name Timer.delayedCall + * @memberof Timer + * + * @function + * @param time + */ + this.delayedCall = function(time) { + let promise = Promise.create(); + _this.create(promise.resolve, time); + return promise; + } + + /** + * Defer callback until next frame + * @name window.defer + * @memberof Timer + * + * @function + * @param {Function} callback + */ + window.defer = this.defer = function(callback) { + let promise; + if (!callback) { + promise = Promise.create(); + callback = promise.resolve; + } + + let array = _defer == _deferA ? _deferB : _deferA; + array.unshift(callback); + return promise; + }; + +}, 'static'); +/** + * Events class + * @name Events + */ + +Class(function Events() { + const _this = this; + this.events = {}; + + const _e = {}; + const _linked = []; + let _emitter; + + /** + * Add event listener + * @name this.events.sub + * @memberof Events + * + * @function + * @param {Object} [obj] - Optional local object to listen upon, prevents event from going global + * @param {String} evt - Event string + * @param {Function} callback - Callback function + * @returns {Function} callback - Returns callback to be immediately triggered + * @example + * // Global event listener + * _this.events.sub(Events.RESIZE, resize); + * function resize(e) {}; + * @example + * // Local event listener + * _this.events.sub(_someClass, Events.COMPLETE, loaded); + * function loaded(e) {}; + * @example + * // Custom event + * MyClass.READY = 'my_class_ready'; + * _this.events.sub(MyClass.READY, ready); + * function ready(e) {}; + */ + this.events.sub = function(obj, evt, callback) { + if (typeof obj !== 'object') { + callback = evt; + evt = obj; + obj = null; + } + + if (!obj) { + Events.emitter._addEvent(evt, !!callback.resolve ? callback.resolve : callback, this); + return callback; + } + + let emitter = obj.events.emitter(); + emitter._addEvent(evt, !!callback.resolve ? callback.resolve : callback, this); + emitter._saveLink(this); + _linked.push(emitter); + + return callback; + }; + + this.events.wait = async function(obj, evt) { + const promise = Promise.create(); + const args = [obj, evt, (e) => { + _this.events.unsub(...args); + promise.resolve(e); + }]; + if (typeof obj !== 'object') { + args.splice(1, 1); + } + _this.events.sub(...args); + return promise; + }; + + /** + * Remove event listener + * @name this.events.unsub + * @memberof Events + * + * @function + * @param {Object} [obj] - Optional local object + * @param {String} evt - Event string + * @param {Function} callback - Callback function + * @example + * // Global event listener + * _this.events.unsub(Events.RESIZE, resize); + * @example + * // Local event listener + * _this.events.unsub(_someClass, Events.COMPLETE, loaded); + */ + this.events.unsub = function(obj, evt, callback) { + if (typeof obj !== 'object') { + callback = evt; + evt = obj; + obj = null; + } + + if (!obj) return Events.emitter._removeEvent(evt, !!callback.resolve ? callback.resolve : callback); + obj.events.emitter()._removeEvent(evt, !!callback.resolve ? callback.resolve : callback); + }; + + /** + * Fire event + * @name this.events.fire + * @memberof Events + * + * @function + * @param {String} evt - Event string + * @param {Object} [obj] - Optional passed data + * @param {Boolean} [isLocalOnly] - If true, prevents event from going global if no-one is listening locally + * @example + * // Passing data with event + * const data = {}; + * _this.events.fire(Events.COMPLETE, {data}); + * _this.events.sub(Events.COMPLETE, e => console.log(e.data); + * @example + * // Custom event + * MyClass.READY = 'my_class_ready'; + * _this.events.fire(MyClass.READY); + */ + this.events.fire = function(evt, obj, isLocalOnly) { + obj = obj || _e; + obj.target = this; + Events.emitter._check(evt); + if (_emitter && _emitter._fireEvent(evt, obj)) return; + if (isLocalOnly) return; + Events.emitter._fireEvent(evt, obj); + }; + + /** + * Bubble up local event - subscribes locally and re-emits immediately + * @name this.events.bubble + * @memberof Events + * + * @function + * @param {Object} obj - Local object + * @param {String} evt - Event string + * @example + * _this.events.bubble(_someClass, Events.COMPLETE); + */ + this.events.bubble = function(obj, evt) { + _this.events.sub(obj, evt, e => _this.events.fire(evt, e)); + }; + + /** + * Destroys all events and notifies listeners to remove reference + * @private + * @name this.events.destroy + * @memberof Events + * + * @function + * @returns {null} + */ + this.events.destroy = function() { + Events.emitter._destroyEvents(this); + if (_linked) _linked.forEach(emitter => emitter._destroyEvents(this)); + if (_emitter && _emitter.links) _emitter.links.forEach(obj => obj.events && obj.events._unlink(_emitter)); + return null; + }; + + /** + * Gets and creates local emitter if necessary + * @private + * @name this.events.emitter + * @memberof Events + * + * @function + * @returns {Emitter} + */ + this.events.emitter = function() { + if (!_emitter) _emitter = Events.emitter.createLocalEmitter(); + return _emitter; + }; + + /** + * Unlink reference of local emitter upon its destruction + * @private + * @name this.events._unlink + * @memberof Events + * + * @function + * @param {Emitter} emitter + */ + this.events._unlink = function(emitter) { + _linked.remove(emitter); + }; +}, () => { + + /** + * Global emitter + * @private + * @name Events.emitter + * @memberof Events + */ + Events.emitter = new Emitter(); + Events.broadcast = Events.emitter._fireEvent; + + Events.VISIBILITY = 'hydra_visibility'; + Events.HASH_UPDATE = 'hydra_hash_update'; + Events.COMPLETE = 'hydra_complete'; + Events.PROGRESS = 'hydra_progress'; + Events.CONNECTIVITY = 'hydra_connectivity'; + Events.UPDATE = 'hydra_update'; + Events.LOADED = 'hydra_loaded'; + Events.END = 'hydra_end'; + Events.FAIL = 'hydra_fail'; + Events.SELECT = 'hydra_select'; + Events.ERROR = 'hydra_error'; + Events.READY = 'hydra_ready'; + Events.RESIZE = 'hydra_resize'; + Events.CLICK = 'hydra_click'; + Events.HOVER = 'hydra_hover'; + Events.MESSAGE = 'hydra_message'; + Events.ORIENTATION = 'orientation'; + Events.BACKGROUND = 'background'; + Events.BACK = 'hydra_back'; + Events.PREVIOUS = 'hydra_previous'; + Events.NEXT = 'hydra_next'; + Events.RELOAD = 'hydra_reload'; + Events.UNLOAD = 'hydra_unload'; + Events.FULLSCREEN = 'hydra_fullscreen'; + + const _e = {}; + + function Emitter() { + const prototype = Emitter.prototype; + this.events = []; + + if (typeof prototype._check !== 'undefined') return; + prototype._check = function(evt) { + if (typeof evt == 'undefined') throw 'Undefined event'; + }; + + prototype._addEvent = function(evt, callback, object) { + this._check(evt); + this.events.push({evt, object, callback}); + }; + + prototype._removeEvent = function(eventString, callback) { + this._check(eventString); + + for (let i = this.events.length - 1; i >= 0; i--) { + if (this.events[i].evt === eventString && this.events[i].callback === callback) { + this._markForDeletion(i); + } + } + }; + + prototype._sweepEvents = function() { + for (let i = 0; i < this.events.length; i++) { + if (this.events[i].markedForDeletion) { + delete this.events[i].markedForDeletion; + this.events.splice(i, 1); + --i; + } + } + } + + prototype._markForDeletion = function(i) { + this.events[i].markedForDeletion = true; + if (this._sweepScheduled) return; + this._sweepScheduled = true; + defer(() => { + this._sweepScheduled = false; + this._sweepEvents(); + }); + } + + prototype._fireEvent = function(eventString, obj) { + if (this._check) this._check(eventString); + obj = obj || _e; + let called = false; + for (let i = 0; i < this.events.length; i++) { + let evt = this.events[i]; + if (evt.evt == eventString && !evt.markedForDeletion) { + evt.callback(obj); + called = true; + } + } + return called; + }; + + prototype._destroyEvents = function(object) { + for (var i = this.events.length - 1; i >= 0; i--) { + if (this.events[i].object === object) { + this._markForDeletion(i); + } + } + }; + + prototype._saveLink = function(obj) { + if (!this.links) this.links = []; + if (!~this.links.indexOf(obj)) this.links.push(obj); + }; + + prototype.createLocalEmitter = function() { + return new Emitter(); + }; + } + + // Global Events + Hydra.ready(() => { + + /** + * Visibility event handler + * @private + */ + (function() { + let _lastTime = performance.now(); + let _last; + + Timer.create(addVisibilityHandler, 250); + + function addVisibilityHandler() { + let hidden, eventName; + [ + ['msHidden', 'msvisibilitychange'], + ['webkitHidden', 'webkitvisibilitychange'], + ['hidden', 'visibilitychange'] + ].forEach(d => { + if (typeof document[d[0]] !== 'undefined') { + hidden = d[0]; + eventName = d[1]; + } + }); + + if (!eventName) { + const root = Device.browser == 'ie' ? document : window; + root.onfocus = onfocus; + root.onblur = onblur; + return; + } + + document.addEventListener(eventName, () => { + const time = performance.now(); + if (time - _lastTime > 10) { + if (document[hidden] === false) onfocus(); + else onblur(); + } + _lastTime = time; + }); + } + + function onfocus() { + Render.blurTime = -1; + if (_last != 'focus') Events.emitter._fireEvent(Events.VISIBILITY, {type: 'focus'}); + _last = 'focus'; + } + + function onblur() { + Render.blurTime = Date.now(); + if (_last != 'blur') Events.emitter._fireEvent(Events.VISIBILITY, {type: 'blur'}); + _last = 'blur'; + } + + window.addEventListener('online', _ => Events.emitter._fireEvent(Events.CONNECTIVITY, {online: true})); + window.addEventListener('offline', _ => Events.emitter._fireEvent(Events.CONNECTIVITY, {online: false})); + + window.onbeforeunload = _ => { + Events.emitter._fireEvent(Events.UNLOAD); + return null; + }; + })(); + + window.Stage = window.Stage || {}; + let box; + if (Device.system.browser == 'social' && Device.system.os == 'ios') { + box = document.createElement('div'); + box.style.position = 'fixed'; + box.style.top = box.style.left = box.style.right = box.style.bottom = '0px'; + box.style.zIndex = '-1'; + box.style.opacity = '0'; + box.style.pointerEvents = 'none'; + document.body.appendChild(box); + } + updateStage(); + + let iosResize = Device.system.os === 'ios'; + let html = iosResize ? document.querySelector('html') : false; + let delay = iosResize ? 500 : 16; + let timer; + + function handleResize() { + clearTimeout(timer); + timer = setTimeout(_ => { + updateStage(); + if ( html && Math.min( window.screen.width, window.screen.height ) !== Stage.height && !Mobile.isAllowNativeScroll ) { + html.scrollTop = -1; + } + Events.emitter._fireEvent(Events.RESIZE); + }, delay); + } + + window.addEventListener('resize', handleResize); + + window.onorientationchange = window.onresize; + + if (Device.system.browser == 'social' && (Stage.height >= screen.height || Stage.width >= screen.width)) { + setTimeout(updateStage, 1000); + } + + // Call initially + defer(window.onresize); + + function updateStage() { + if (box) { + let bbox = box.getBoundingClientRect(); + Stage.width = bbox.width || window.innerWidth || document.body.clientWidth || document.documentElement.offsetWidth; + Stage.height = bbox.height || window.innerHeight || document.body.clientHeight || document.documentElement.offsetHeight; + + document.body.parentElement.scrollTop = document.body.scrollTop = 0; + document.documentElement.style.width = document.body.style.width = `${Stage.width}px`; + document.documentElement.style.height = document.body.style.height = `${Stage.height}px`; + Events.emitter._fireEvent(Events.RESIZE); + } else { + Stage.width = window.innerWidth || document.body.clientWidth || document.documentElement.offsetWidth; + Stage.height = window.innerHeight || document.body.clientHeight || document.documentElement.offsetHeight; + } + } + }); +}); +/** + * Read-only class with device-specific information and exactly what's supported. + * Information split into: system, mobile, media, graphics, style, tween. + * @name Device + */ + +Class(function Device() { + var _this = this; + + /** + * Stores user agent as string + * @name Device.agent + * @memberof Device + */ + this.agent = navigator.userAgent.toLowerCase(); + + /** + * Checks user agent against match query + * @name Device.detect + * @memberof Device + * + * @function + * @param {String|String[]} match - Either string or array of strings to test against + * @returns {Boolean} + */ + this.detect = function(match) { + return this.agent.includes(match) + }; + + /** + * Boolean + * @name Device.touchCapable + * @memberof Device + */ + this.touchCapable = !!navigator.maxTouchPoints; + + /** + * Alias of window.devicePixelRatio + * @name Device.pixelRatio + * @memberof Device + */ + this.pixelRatio = window.devicePixelRatio; + + //==================================================================================// + //===// System //===================================================================// + + this.system = {}; + + /** + * Boolean. True if devicePixelRatio greater that 1.0 + * @name Device.system.retina + * @memberof Device + */ + this.system.retina = window.devicePixelRatio > 1; + + /** + * Boolean + * @name Device.system.webworker + * @memberof Device + */ + this.system.webworker = typeof window.Worker !== 'undefined'; + + + /** + * Boolean + * @name Device.system.geolocation + * @memberof Device + */ + if (!window._NODE_) this.system.geolocation = typeof navigator.geolocation !== 'undefined'; + + /** + * Boolean + * @name Device.system.pushstate + * @memberof Device + */ + if (!window._NODE_) this.system.pushstate = typeof window.history.pushState !== 'undefined'; + + /** + * Boolean + * @name Device.system.webcam + * @memberof Device + */ + this.system.webcam = !!(navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.mediaDevices); + + /** + * String of user's navigator language + * @name Device.system.language + * @memberof Device + */ + this.system.language = window.navigator.userLanguage || window.navigator.language; + + /** + * Boolean + * @name Device.system.webaudio + * @memberof Device + */ + this.system.webaudio = typeof window.AudioContext !== 'undefined'; + + /** + * Boolean + * @name Device.system.xr + * @memberof Device + */ + this.system.xr = {}; + this.system.detectXR = async function() { + if (window.AURA) { + _this.system.xr.vr = true; + _this.system.xr.ar = true; + return; + } + + if (!navigator.xr) { + _this.system.xr.vr = false; + _this.system.xr.ar = false; + return; + } + + try { + [_this.system.xr.vr, _this.system.xr.ar] = await Promise.all([ + navigator.xr.isSessionSupported('immersive-vr'), + navigator.xr.isSessionSupported('immersive-ar') + ]); + } catch(e) { } + + if (_this.system.os == 'android') { + if (!_this.detect('oculus')) { + _this.system.xr.vr = false; + } + } + }; + + /** + * Boolean + * @name Device.system.localStorage + * @memberof Device + */ + try { + this.system.localStorage = typeof window.localStorage !== 'undefined'; + } catch (e) { + this.system.localStorage = false; + } + + /** + * Boolean + * @name Device.system.fullscreen + * @memberof Device + */ + this.system.fullscreen = document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled; + + function detectIpad() { + let aspect = Math.max(screen.width, screen.height) / Math.min(screen.width, screen.height); + // iPads getting bigger and bigger, 2022 iPad Pro is 1389x970. But the aspect + // ratio has stayed consistent: iPads are all 4:3, Macbooks are 16:10. + // Need to account for external displays too, but they're likely to be + // closer to 16:10 than 4:3 + return _this.detect('mac') && _this.touchCapable && Math.abs(aspect - 4/3) < Math.abs(aspect - 16/10); + } + + /** + * String of operating system. Returns 'ios', 'android', 'blackberry', 'mac', 'windows', 'linux' or 'unknown'. + * @name Device.system.os + * @memberof Device + */ + this.system.os = (function() { + if (_this.detect(['ipad', 'iphone', 'ios']) || detectIpad()) return 'ios'; + if (_this.detect(['android', 'kindle'])) return 'android'; + if (_this.detect(['blackberry'])) return 'blackberry'; + if (_this.detect(['mac os'])) return 'mac'; + if (_this.detect(['windows', 'iemobile'])) return 'windows'; + if (_this.detect(['linux'])) return 'linux'; + return 'unknown'; + })(); + + /** + * Mobile os version. Currently only applicable to mobile OS. + * @name Device.system.version + * @memberof Device + */ + this.system.version = (function() { + try { + if (_this.system.os == 'ios') { + if (_this.agent.includes('intel mac')) { + let num = _this.agent.split('version/')[1].split(' ')[0]; + let split = num.split('.'); + return Number(split[0] + '.' + split[1]); + } else { + var num = _this.agent.split('os ')[1].split('_'); + var main = num[0]; + var sub = num[1].split(' ')[0]; + return Number(main + '.' + sub); + } + } + if (_this.system.os == 'android') { + var version = _this.agent.split('android ')[1].split(';')[0]; + if (version.length > 3) version = version.slice(0, -2); + if (version.charAt(version.length-1) == '.') version = version.slice(0, -1); + return Number(version); + } + if (_this.system.os == 'windows') { + if (_this.agent.includes('rv:11')) return 11; + return Number(_this.agent.split('windows phone ')[1].split(';')[0]); + } + } catch(e) {} + return -1; + })(); + + /** + * String of browser. Returns, 'social, 'chrome', 'safari', 'firefox', 'ie', 'browser' (android), or 'unknown'. + * @name Device.system.browser + * @memberof Device + */ + this.system.browser = (function() { + if (_this.system.os == 'ios') { + if (_this.detect(['twitter', 'fbios', 'instagram'])) return 'social'; + if (_this.detect(['crios'])) return 'chrome'; + if (_this.detect(['fxios'])) return 'firefox'; + if (_this.detect(['safari'])) return 'safari'; + return 'unknown'; + } + if (_this.system.os == 'android') { + if (_this.detect(['twitter', 'fb', 'facebook', 'instagram'])) return 'social'; + if (_this.detect(['chrome'])) return 'chrome'; + if (_this.detect(['firefox'])) return 'firefox'; + return 'browser'; + } + if (_this.detect(['msie'])) return 'ie'; + if (_this.detect(['trident']) && _this.detect(['rv:'])) return 'ie'; + if (_this.detect(['windows']) && _this.detect(['edge'])) return 'ie'; + if (_this.detect(['chrome'])) return 'chrome'; + if (_this.detect(['safari'])) return 'safari'; + if (_this.detect(['firefox'])) return 'firefox'; + + // TODO: test windows phone and see what it returns + //if (_this.os == 'Windows') return 'ie'; + return 'unknown'; + })(); + + /** + * Number value of browser version + * @name Device.browser.browserVersion + * @memberof Device + */ + this.system.browserVersion = (function() { + try { + if (_this.system.browser == 'chrome') { + if (_this.detect('crios')) return Number(_this.agent.split('crios/')[1].split('.')[0]); + return Number(_this.agent.split('chrome/')[1].split('.')[0]); + } + if (_this.system.browser == 'firefox') return Number(_this.agent.split('firefox/')[1].split('.')[0]); + if (_this.system.browser == 'safari') return Number(_this.agent.split('version/')[1].split('.')[0].split('.')[0]); + if (_this.system.browser == 'ie') { + if (_this.detect(['msie'])) return Number(_this.agent.split('msie ')[1].split('.')[0]); + if (_this.detect(['rv:'])) return Number(_this.agent.split('rv:')[1].split('.')[0]); + return Number(_this.agent.split('edge/')[1].split('.')[0]); + } + } catch(e) { + return -1; + } + })(); + + //==================================================================================// + //===// Mobile //===================================================================// + + /** + * Object that only exists if device is mobile or tablet + * @name Device.mobile + * @memberof Device + */ + this.mobile = !window._NODE_ && (!!(('ontouchstart' in window) || ('onpointerdown' in window)) && _this.system.os.includes(['ios', 'android', 'magicleap'])) ? {} : false; + if (_this.detect('oculusbrowser')) this.mobile = true; + if (_this.detect('quest')) this.mobile = true; + if (this.mobile && this.detect(['windows']) && !this.detect(['touch'])) this.mobile = false; + if (this.mobile) { + + /** + * Boolean + * @name Device.mobile.tablet + * @memberof Device + */ + this.mobile.tablet = Math.max(window.screen ? screen.width : window.innerWidth, window.screen ? screen.height : window.innerHeight) > 1000; + + /** + * Boolean + * @name Device.mobile.phone + * @memberof Device + */ + this.mobile.phone = !this.mobile.tablet; + + /** + * Boolean + * @name Device.mobile.pwa + * @memberof Device + */ + this.mobile.pwa = (function() { + if (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) return true; + if (window.navigator.standalone) return true; + return false; + })(); + + /** + * Boolean. Only available after Hydra is ready + * @name Device.mobile.native + * @memberof Device + */ + Hydra.ready(() => { + _this.mobile.native = (function() { + if (Mobile.NativeCore && Mobile.NativeCore.active) return true; + if (window._AURA_) return true; + return false; + })(); + }); + } + + //=================================================================================// + //===// Media //===================================================================// + + this.media = {}; + + /** + * String for preferred audio format ('ogg' or 'mp3'), else false if unsupported + * @name Device.media.audio + * @memberof Device + */ + this.media.audio = (function() { + if (!!document.createElement('audio').canPlayType) { + return _this.detect(['firefox', 'opera']) ? 'ogg' : 'mp3'; + } else { + return false; + } + })(); + + /** + * String for preferred video format ('webm', 'mp4' or 'ogv'), else false if unsupported + * @name Device.media.video + * @memberof Device + */ + this.media.video = (function() { + var vid = document.createElement('video'); + if (!!vid.canPlayType) { + if (vid.canPlayType('video/webm;')) return 'webm'; + return 'mp4'; + } else { + return false; + } + })(); + + /** + * Boolean + * @name Device.media.webrtc + * @memberof Device + */ + this.media.webrtc = !!(window.webkitRTCPeerConnection || window.mozRTCPeerConnection || window.msRTCPeerConnection || window.oRTCPeerConnection || window.RTCPeerConnection); + + //====================================================================================// + //===// Graphics //===================================================================// + + this.graphics = {}; + + /** + * Object with WebGL-related information. False if WebGL unsupported. + * @name Device.graphics.webgl + * @memberof Device + * @example + * Device.graphics.webgl.renderer + * Device.graphics.webgl.version + * Device.graphics.webgl.glsl + * Device.graphics.webgl.extensions + * Device.graphics.webgl.gpu + * Device.graphics.webgl.extensions + */ + this.graphics.webgl = (function() { + + let DISABLED = false; + + Object.defineProperty(_this.graphics, 'webgl', { + get: () => { + if (DISABLED) return false; + + if (_this.graphics._webglContext) return _this.graphics._webglContext; + + try { + const names = ['webgl2', 'webgl', 'experimental-webgl']; + const canvas = document.createElement('canvas'); + let gl; + for (let i = 0; i < names.length; i++) { + if (names[i] === 'webgl2' && Utils.query('compat')) continue; + gl = canvas.getContext(names[i]); + if (gl) break; + } + + let output = { gpu: 'unknown' }; + output.renderer = gl.getParameter(gl.RENDERER).toLowerCase(); + output.version = gl.getParameter(gl.VERSION).toLowerCase(); + output.glsl = gl.getParameter(gl.SHADING_LANGUAGE_VERSION).toLowerCase(); + output.extensions = gl.getSupportedExtensions(); + output.webgl2 = output.version.includes(['webgl 2', 'webgl2']); + output.canvas = canvas; + output.context = gl; + + if (_this.system.browser === 'firefox' && _this.system.browserVersion >= 92) { + // WEBGL_debug_renderer_info deprecated in Firefox since 92, with + // “sanitized” gpu moved to the RENDERER parameter. See + // https://bugzil.la/1722782 and https://bugzil.la/1722113 + output.gpu = output.renderer; + } else { + let info = gl.getExtension('WEBGL_debug_renderer_info'); + if (info) { + let gpu = info.UNMASKED_RENDERER_WEBGL; + output.gpu = gl.getParameter(gpu).toLowerCase(); + } + } + + output.detect = function(matches) { + if (output.gpu && output.gpu.toLowerCase().includes(matches)) return true; + if (output.version && output.version.toLowerCase().includes(matches)) return true; + + for (let i = 0; i < output.extensions.length; i++) { + if (output.extensions[i].toLowerCase().includes(matches)) return true; + } + return false; + }; + + if (!output.webgl2 && !output.detect('instance') && !window.AURA) DISABLED = true; + + _this.graphics._webglContext = output; + return output; + } catch(e) { + return false; + } + }, + + set: v => { + if (v === false) DISABLED = true; + } + }); + })(); + + this.graphics.metal = (function() { + if (!window.Metal) return false; + let output = {}; + output.gpu = Metal.device.getName().toLowerCase(); + output.detect = function(matches) { + return output.gpu.includes(matches); + }; + return output; + })(); + + /** + * Abstraction of Device.graphics.webgl to handle different rendering backends + * + * @name Device.graphics.gpu + * @memberof Device + */ + this.graphics.gpu = (function() { + if (!_this.graphics.webgl && !_this.graphics.metal) return false; + let output = {}; + ['metal', 'webgl'].forEach(name => { + if (!!_this.graphics[name] && !output.identifier) { + output.detect = _this.graphics[name].detect; + output.identifier = _this.graphics[name].gpu; + } + }); + return output; + })(); + + /** + * Boolean + * @name Device.graphics.canvas + * @memberof Device + */ + this.graphics.canvas = (function() { + var canvas = document.createElement('canvas'); + return canvas.getContext ? true : false; + })(); + + //==================================================================================// + //===// Styles //===================================================================// + + const checkForStyle = (function() { + let _tagDiv; + return function (prop) { + _tagDiv = _tagDiv || document.createElement('div'); + const vendors = ['Khtml', 'ms', 'O', 'Moz', 'Webkit'] + if (prop in _tagDiv.style) return true; + prop = prop.replace(/^[a-z]/, val => {return val.toUpperCase()}); + for (let i = vendors.length - 1; i >= 0; i--) if (vendors[i] + prop in _tagDiv.style) return true; + return false; + } + })(); + + this.styles = {}; + + /** + * Boolean + * @name Device.styles.filter + * @memberof Device + */ + this.styles.filter = checkForStyle('filter'); + + /** + * Boolean + * @name Device.styles.blendMode + * @memberof Device + */ + this.styles.blendMode = checkForStyle('mix-blend-mode'); + + //=================================================================================// + //===// Tween //===================================================================// + + this.tween = {}; + + /** + * Boolean + * @name Device.tween.transition + * @memberof Device + */ + this.tween.transition = checkForStyle('transition'); + + /** + * Boolean + * @name Device.tween.css2d + * @memberof Device + */ + this.tween.css2d = checkForStyle('transform'); + + /** + * Boolean + * @name Device.tween.css3d + * @memberof Device + */ + this.tween.css3d = checkForStyle('perspective'); + + //==================================================================================// + //===// Social //===================================================================// + + /** + * Boolean + * @name Device.social + * @memberof Device + */ + this.social = (function() { + if (_this.agent.includes('instagram')) return 'instagram'; + if (_this.agent.includes('fban')) return 'facebook'; + if (_this.agent.includes('fbav')) return 'facebook'; + if (_this.agent.includes('fbios')) return 'facebook'; + if (_this.agent.includes('twitter')) return 'twitter'; + if (document.referrer && document.referrer.includes('//t.co/')) return 'twitter'; + return false; + })(); +}, 'Static'); + +/** + * Class structure tool-belt that cleans up after itself upon class destruction. + * @name Component + */ + +Class(function Component() { + Inherit(this, Events); + const _this = this; + const _setters = {}; + const _flags = {}; + const _timers = []; + const _loops = []; + var _onDestroy, _appStateBindings; + + this.classes = {}; + + function defineSetter(_this, prop) { + _setters[prop] = {}; + Object.defineProperty(_this, prop, { + set: function(v) { + if (_setters[prop] && _setters[prop].s) _setters[prop].s.call(_this, v); + v = null; + }, + + get: function() { + if (_setters[prop] && _setters[prop].g) return _setters[prop].g.apply(_this); + } + }); + } + + /** + * @name this.findParent + * @memberof Component + * + * @function + * @param type + */ + this.findParent = function(type) { + let p = _this.parent; + while (p) { + if (!p._cachedName) p._cachedName = Utils.getConstructorName(p); + if (p._cachedName == type) return p; + p = p.parent; + } + } + + /** + * Define setter for class property + * @name this.set + * @memberof Component + * + * @function + * @param {String} prop + * @param {Function} callback + */ + this.set = function(prop, callback) { + if (!_setters[prop]) defineSetter(this, prop); + _setters[prop].s = callback; + }; + + /** + * Define getter for class property + * @name this.get + * @memberof Component + * + * @function + * @param {String} prop + * @param {Function} callback + */ + this.get = function(prop, callback) { + if (!_setters[prop]) defineSetter(this, prop); + _setters[prop].g = callback; + }; + + /** + * Returns true if the current playground is set to this class + * @name this.set + * @memberof Component + * + * @function + */ + this.isPlayground = function(name) { + return Global.PLAYGROUND && Global.PLAYGROUND == (name || Utils.getConstructorName(_this)); + }; + + + /** + * Helper to initialise class and keep reference for automatic cleanup upon class destruction + * @name this.initClass + * @memberof Component + * + * @function + * @param {Function} clss - class to initialise + * @param {*} arguments - All additional arguments passed to class constructor + * @returns {Object} - Instanced child class + * @example + * Class(function BigButton(_color) { + * console.log(`${this.parent} made me ${_color}); //logs [parent object] made me red + * }); + * const bigButton _this.initClass(BigButton, 'red'); + */ + this.initClass = function(clss) { + if (!clss) { + console.trace(); + throw `unable to locate class`; + } + + const args = [].slice.call(arguments, 1); + const child = Object.create(clss.prototype); + child.parent = this; + child.__afterInitClass = []; + clss.apply(child, args); + + // Store reference if child is type Component + if (child.destroy) { + const id = Utils.timestamp(); + this.classes[id] = child; + this.classes[id].__id = id; + } + + // Automatically attach HydraObject elements + if (child.element) { + const last = arguments[arguments.length - 1]; + if (Array.isArray(last) && last.length == 1 && last[0] instanceof HydraObject) last[0].add(child.element); + else if (this.element && this.element.add && last !== null) this.element.add(child.element); + } + + // Automatically attach 3D groups + if (child.group) { + const last = arguments[arguments.length - 1]; + if (this.group && last !== null) this.group.add(child.group); + } + + child.__afterInitClass.forEach(callback => { + callback(); + }); + delete child.__afterInitClass; + + return child; + }; + + /** + * Create timer callback with automatic cleanup upon class destruction + * @name this.delayedCall + * @memberof Component + * + * @function + * @param {Function} callback + * @param {Number} time + * @param {*} [args] - any number of arguments can be passed to callback + */ + this.delayedCall = function(callback, time, scaledTime) { + const timer = Timer.create(() => { + if (!_this || !_this.destroy) return; + callback && callback(); + }, time, scaledTime); + + _timers.push(timer); + + // Limit in case dev using a very large amount of timers, so not to local reference + if (_timers.length > 50) _timers.shift(); + + return timer; + }; + + /** + * Clear all timers linked to this class + * @name this.clearTimers + * @memberof Component + * + * @function + */ + this.clearTimers = function() { + for (let i = _timers.length - 1; i >= 0; i--) clearTimeout(_timers[i]); + _timers.length = 0; + }; + + /** + * Start render loop. Stored for automatic cleanup upon class destruction + * @name this.startRender + * @memberof Component + * + * @function + * @param {Function} callback + * @param {Number} [fps] Limit loop rate to number of frames per second. eg Value of 1 will trigger callback once per second + */ + this.startRender = function(callback, fps, obj) { + if (typeof fps !== 'number') { + obj = fps; + fps = undefined; + } + + for (let i = 0; i < _loops.length; i++) { + if (_loops[i].callback == callback) return; + } + + let flagInvisible = _ => { + if (!_this._invisible) { + _this._invisible = true; + _this.onInvisible && _this.onInvisible(); + } + }; + + let loop = (a, b, c, d) => { + if (!_this.startRender) return false; + + let p = _this; + while (p) { + if (p.visible === false) return flagInvisible(); + if (p.group && p.group.visible === false) return flagInvisible(); + p = p.parent; + } + + if (_this._invisible !== false) { + _this._invisible = false; + _this.onVisible && _this.onVisible(); + } + + callback(a, b, c, d); + return true; + }; + _loops.push({callback, loop}); + + if (obj) { + if (obj == RenderManager.NATIVE_FRAMERATE) Render.start(loop, null, true); + else RenderManager.schedule(loop, obj); + } else { + Render.start(loop, fps); + } + }; + + /** + * Link up to the resize event + * @name this.resize + * @memberof Component + * + * @function + * @param {Function} callback + * @param {Boolean} callInitial + */ + this.onResize = function(callback, callInitial = true) { + if (callInitial) callback(); + + this.events.sub(Events.RESIZE, callback); + } + + /** + * Stop and clear render loop linked to callback + * @name this.stopRender + * @memberof Component + * + * @function + * @param {Function} callback + */ + this.stopRender = function(callback, obj) { + for (let i = 0; i < _loops.length; i++) { + if (_loops[i].callback == callback) { + + let loop = _loops[i].loop; + + if (obj) { + RenderManager.unschedule(loop, obj); + } + + Render.stop(loop); + _loops.splice(i, 1); + } + } + }; + + /** + * Clear all render loops linked to this class + * @name this.clearRenders + * @memberof Component + * + * @function + */ + this.clearRenders = function() { + for (let i = 0; i < _loops.length; i++) { + Render.stop(_loops[i].loop); + } + + _loops.length = 0; + }; + + /** + * Get callback when object key exists. Uses internal render loop so automatically cleaned up. + * @name this.wait + * @memberof Component + * + * @function + * @param {Object} object + * @param {String} key + * @param {Function} [callback] - Optional callback + * @example + * // Using promise syntax + * this.wait(this, 'loaded').then(() => console.log('LOADED')); + * @example + * // Omitting object defaults to checking for a property on `this` + * await this.wait('loaded'); console.log('LOADED'); + * @example + * // Waiting for a property to flip from truthy to falsy + * await this.wait('!busy'); console.log('ready'); + * @example + * // Using callback + * this.wait(this, 'loaded', () => console.log('LOADED')); + * @example + * // Using custom condition + * await this.wait(() => _count > 3); console.log('done'); + * @example + * // Wait for a number of milliseconds + * await this.wait(500); console.log('timeout'); + */ + this.wait = function(object, key, callback) { + const promise = Promise.create(); + let condition; + + if (typeof object === 'string') { + callback = key; + key = object; + object = _this; + } + + if (typeof object === 'number' && arguments.length === 1) { + _this.delayedCall(promise.resolve, object); + return promise; + } + + if (typeof object === 'function' && arguments.length === 1) { + condition = object; + object = _this; + } + + // To catch old format of first param being callback + if (typeof object == 'function' && typeof callback === 'string') { + let _object = object; + object = key; + key = callback; + callback = _object; + } + + callback = callback || promise.resolve; + + if (!condition) { + if (key?.charAt?.(0) === '!') { + key = key.slice(1); + condition = () => !(object[key] || (typeof object.flag === 'function' && object.flag(key))); + } else { + condition = () => !!object[key] || !!(typeof object.flag === 'function' && object.flag(key)); + } + } + + if (condition()) { + callback(); + } else { + Render.start(test); + + function test() { + if (!object || !_this.flag || object.destroy === null) return Render.stop(test); + if (condition()) { + callback(); + Render.stop(test); + } + } + } + + return promise; + }; + + /** + * Bind to an AppState to get your binding automatically cleaned up on destroy + * @name this.bindState + * @memberof Component + * + * @function + * @param {AppState} AppState + * @param {String} [key] Key name + * @param {Any} [rest] - Callback or otherwise second parameter to pass to AppState.bind + */ + this.bindState = async function(appState, key, ...rest) { + if (!!appState.then) appState = await appState; + if (!_appStateBindings) _appStateBindings = []; + // if(!(appState._bind || appState.bind)) console.log(appState) + let fn = (appState._bind || appState.bind).bind(appState); + let binding = fn(key, ...rest); + _appStateBindings.push(binding); + + binding._bindOnDestroy(() => { + _appStateBindings.remove(binding); + }); + + return binding; + } + + /** + * Set or get boolean + * @name this.flag + * @memberof Component + * + * @function + * @param {String} name + * @param {Boolean} [value] if no value passed in, current value returned + * @param {Number} [time] - Optional delay before toggling the value to the opposite of its current value + * @returns {*} Returns with current value if no value passed in + */ + this.flag = function(name, value, time) { + if (typeof value !== 'undefined') { + _flags[name] = value; + + if (time) { + clearTimeout(_flags[name+'_timer']); + _flags[name+'_timer'] = this.delayedCall(() => { + _flags[name] = !_flags[name]; + }, time); + } + } else { + return _flags[name]; + } + }; + + /** + * Destroy class and all of its attachments: events, timers, render loops, children. + * @name this.destroy + * @memberof Component + * + * @function + */ + this.destroy = function() { + if (this.removeDispatch) this.removeDispatch(); + if (this.onDestroy) this.onDestroy(); + if (this.fxDestroy) this.fxDestroy(); + if (_onDestroy) _onDestroy.forEach(cb => cb()); + + for (let id in this.classes) { + var clss = this.classes[id]; + if (clss && clss.destroy) clss.destroy(); + } + this.classes = null; + + this.clearRenders && this.clearRenders(); + this.clearTimers && this.clearTimers(); + if (this.element && window.GLUI && this.element instanceof GLUIObject) this.element.remove(); + + if (this.events) this.events = this.events.destroy(); + if (this.parent && this.parent.__destroyChild) this.parent.__destroyChild(this.__id); + + if (_appStateBindings) { + while (_appStateBindings.length > 0) { + // destroying removes itself from the _appStateBindings array + // so keep looking until there are none left, + // since the indexes of _appStateBindings are changing + _appStateBindings[_appStateBindings.length - 1].destroy?.(); + } + } + + return Utils.nullObject(this); + }; + + this._bindOnDestroy = function(cb) { + if (!_onDestroy) _onDestroy = []; + _onDestroy.push(cb); + } + + this.__destroyChild = function(name) { + delete this.classes[name]; + }; + + this.navigate = function(route) { + let p = _this.parent; + while (p) { + if (p.navigate) p.navigate(route); + p = p.parent; + } + } + +}); + +/** + * Class structure tool-belt that helps with loading and storing data. + * @name Model + */ + +Class(function Model() { + Inherit(this, Component); + Namespace(this); + + const _this = this; + const _storage = {}; + const _requests = {}; + let _data = 0; + let _triggered = 0; + + /** + * @name this.push + * @memberof Model + * + * @function + * @param {String} name + * @param {*} val + */ + this.push = function(name, val) { + _storage[name] = val; + }; + + /** + * @name this.pull + * @memberof Model + * + * @function + * @param {String} name + * @returns {*} + */ + this.pull = function(name) { + return _storage[name]; + }; + + /** + * @name this.promiseData + * @memberof Model + * + * @function + * @param {Number} [num = 1] + */ + this.waitForData = this.promiseData = function(num = 1) { + _data += num; + }; + + /** + * @name this.resolveData + * @memberof Model + * + * @function + */ + this.fulfillData = this.resolveData = function() { + _triggered++; + if (_triggered == _data) { + _this.dataReady = true; + } + }; + + /** + * @name this.ready + * @memberof Model + * + * @function + * @param {Function} [callback] + * @returns {Promise} + */ + this.ready = function(callback) { + let promise = Promise.create(); + if (callback) promise.then(callback); + _this.wait(_this, 'dataReady').then(promise.resolve); + return promise; + }; + + /** + * Calls init() on object member is exists, and then on self once completed. + * @name this.initWithData + * @memberof Model + * + * @function + * @param {Object} data + */ + this.initWithData = function(data) { + _this.STATIC_DATA = data; + + for (var key in _this) { + var model = _this[key]; + var init = false; + + for (var i in data) { + if (i.toLowerCase().replace(/-/g, "") == key.toLowerCase()) { + init = true; + if (model.init) model.init(data[i]); + } + } + + if (!init && model.init) model.init(); + } + + _this.init && _this.init(data); + }; + + /** + * Loads url with salt, then calls initWithData on object received + * @name this.loadData + * @memberof Model + * + * @function + * @param {String} url + * @param {Function} [callback] + * @returns {Promise} + */ + this.loadData = function(url, callback) { + let promise = Promise.create(); + if (!callback) callback = promise.resolve; + + var _this = this; + get(url + '?' + Utils.timestamp()).then( d => { + defer(() => { + _this.initWithData(d); + callback(d); + }); + }); + + return promise; + }; + + /** + * @name this.handleRequest + * @memberof Model + * + * @function + * @param {String} type + * @param {Function} [callback] + */ + this.handleRequest = function(type, callback) { + _requests[type] = callback; + } + + /** + * @name this.makeRequest + * @memberof Model + * + * @function + * @param {String} type + * @param {Object} data? + * @param {Object} mockData? + * @returns {Promise} + */ + this.makeRequest = async function(type, data, mockData = {}) { + if (!_requests[type]) { + console.warn(`Missing data handler for ${type} with mockData`, mockData); + return Array.isArray(mockData) ? new StateArray(mockData) : AppState.createLocal(mockData); + } + let result = await _requests[type](data, mockData); + if (!(result instanceof StateArray) && !result.createLocal) throw `makeRequest ${type} must return either an AppState or StateArray`; + return result; + } + + + this.request = async function(type, data, mockData) { + if (typeof data === 'function') { + mockData = data; + data = null; + } + + if (mockData) mockData = mockData(); + + if (!_requests[type]) { + // console.warn(`Missing data handler for ${type} with mockData`, mockData); + return Array.isArray(mockData) ? new StateArray(mockData) : AppState.createLocal(mockData); + } + let result = await _requests[type](data, mockData); + if (Array.isArray(result)) result = new StateArray(result); + else if (typeof result === 'object') result = AppState.createLocal(result); + + if (!(result instanceof StateArray) && !result.createLocal) throw `makeRequest ${type} must return either an AppState or StateArray`; + return result; + } + +}); + +Class(function Data() { + Inherit(this, Model); + const _this = this; +}, 'static'); +/** + * @name Modules + */ + +Class(function Modules() { + const _modules = {}; + const _constructors = {}; + + //*** Constructor + (function () { + defer(exec); + })(); + + function exec() { + for (let m in _modules) { + for (let key in _modules[m]) { + let module = _modules[m][key]; + if (module._ready) continue; + module._ready = true; + if (module.exec) module.exec(); + } + } + } + + function requireModule(root, path) { + let module = _modules[root]; + if (!module) throw `Module ${root} not found`; + module = module[path]; + + if (!module._ready) { + module._ready = true; + if (module.exec) module.exec(); + } + + return module; + } + + //*** Public methods + + /** + * @name window.Module + * @memberof Modules + * + * @function + * @param {Constructor} module + */ + this.Module = function(module) { + let m = new module(); + + let name = module.toString().slice(0, 100).match(/function ([^\(]+)/); + + if (name) { + m._ready = true; + name = name[1]; + _modules[name] = {index: m}; + _constructors[name] = module; + } else { + if (!_modules[m.module]) _modules[m.module] = {}; + _modules[m.module][m.path] = m; + } + }; + + /** + * @name window.require + * @memberof Modules + * + * @function + * @param {String} path + * @returns {*} + */ + this.require = function(path) { + let root; + if (!path.includes('/')) { + root = path; + path = 'index'; + } else { + root = path.split('/')[0]; + path = path.replace(root+'/', ''); + } + + return requireModule(root, path).exports; + }; + + this.getConstructor = function(name) { + return _constructors[name]; + } + + this.modulesReady = async function () { + let modules = [...arguments].flat(); + await Promise.all( modules.map( name => Modules.moduleReady( name ))); + } + + this.moduleReady = function (name) { + let promise = Promise.create(); + let check = function () { + if ( !_modules[name]) return; + Render.stop( check ); + promise.resolve(); + } + Render.start( check ); + return promise; + } + + window.Module = this.Module; + + if (!window._NODE_) { + window.requireNative = window.require; + window.require = this.require; + } +}, 'Static'); + +Class(function StateWrapper(_array) { + const _this = this; + Inherit(this, Component); + + this.bind = this.listen = function(key, callback) { + _array.forEach(async obj => { + await obj.wait('__ready'); + _this.bindState(obj.state, key, data => { + callback({target: obj, data}); + }); + }); + } + +}); +Class(function StateInitializer(Class, _ref, _params, _stateRef) { + Inherit(this, Component); + const _this = this; + + this.ref = _ref; + + //*** Constructor + (function () { + if (!_stateRef.init) throw `StateInitializer required init parameter`; + if (_stateRef.init) _this.bindState(AppState, _stateRef.init, onInit); + if (_stateRef.init3d) _this.bindState(AppState, _stateRef.init3d, onInit3D); + })(); + + function onInit(bool) { + if (bool) { + _this.parent[_ref] = _this.parent.initClass(Class, _params); + } else { + _this.parent[_ref] = _this.parent[_ref].destroy(); + } + } + + async function onInit3D() { + let ref = _this.parent[_ref]; + await _this.wait(_ => ref); + let next = await Initializer3D.queue(); + await Initializer3D.uploadAllAsync(ref.layout || ref.scene || ref.group); + next(); + } + + //*** Event handlers + + //*** Public methods + this.force = function() { + AppState.set(_stateRef.init, true); + } +}); +Class(function FragUIHelper(_obj, _root) { + Inherit(this, Component); + const _this = this; + + if (!_obj.addTo) _obj.addTo = "$element"; + if (_root) applyValues(_root, _this.parent.element); + create(_obj); + + function isLowerCase(str) { + return str == str.toLowerCase(); + } + + function findStateObject(text) { + return text.match(/\$(.*)\./)[1]; + } + + function getPropByString(obj, propString) { + if (!propString) + return obj; + + var prop, props = propString.split('.'); + + for (var i = 0, iLen = props.length - 1; i < iLen; i++) { + prop = props[i]; + + var candidate = obj[prop]; + if (candidate !== undefined) { + obj = candidate; + } else { + break; + } + } + return obj[props[i]]; + } + + function parseTextBindings(text) { + let binds = []; + while (text.match(/\$(.*)\./)) { + let match = text.match(/\$(.*)\./); + let split = text.split(match[0]); + split[0] = split[0] + '@['; + split[1] = split[1].split(' '); + let name = split[1][0]; + split[1][0] += ']'; + split[1] = split[1].join(' '); + text = split.join(''); + binds.push(name); + } + return [binds, text]; + } + + function parseTextGlobalBindings(text) { + let binds = []; + while (text.match(/\$(\w*)\/(\w*)/)) { + let match = text.match(/\$(\w*)\/(\w*)/); + let split = text.split(match[0]); + split[0] = split[0] + '@['; + split[1] = split[1].split(' '); + let name = match[0].slice(1).trim(); + split[1][0] = name; + split[1][0] += ']'; + split[1] = split[1].join(' '); + text = split.join(''); + binds.push(name); + } + return [binds, text]; + } + + function doConstructor(obj) { + switch (obj._type) { + case 'UI': + return _this.parent.element; + break; + + case 'GLObject': + case 'glObject': + case 'glObj': + if (obj.width && obj.height && obj.bg) { + let tempObj = $gl(Number(obj.width), Number(obj.height), obj.bg); + + // calling GLObject.bg() in applyValues() removes the custom shader. + // didn't happen before because we weren't waiting for parent init before calling + delete obj.bg; + + return tempObj; + } else { + return $gl(); + } + break; + + case 'GLText': + case 'glText': + if (obj._innerText.match?.(/\$(.*)\./)) { + let $text = $glText(obj._innerText, obj.font, Number(obj.fontSize), { color: obj.fontColor }); + let state = findStateObject(obj._innerText); + let ref = state; + if (ref.includes('.')) { + let split = state.split('.'); + ref = split[0]; + split.shift(); + state = split.join('.'); + } + _this.wait(_this.parent, ref).then(_ => { + let [binds, text] = parseTextBindings(obj._innerText); + $obj.text(text); + _this.parent.bindState(ref == state ? _this.parent[ref] : getPropByString(_this.parent[ref], state), binds, $obj); + }); + return $text; + } else if (obj._innerText.match?.(/\$(\w*)\/(\w*)/)) { + let [binds, text] = parseTextGlobalBindings(obj._innerText); + let $text = $glText(text, obj.font, Number(obj.fontSize), { color: obj.fontColor }); + _this.parent.bindState(AppState, binds, $text); + return $text; + } else { + return $glText(obj._innerText, obj.font, Number(obj.fontSize), { color: obj.fontColor }); + } + break; + + default: + let $obj = $(obj.className || obj.refName || 'h', obj._type != 'HydraObject' ? obj._type : 'div'); + if (obj.width && obj.height) $obj.size(obj.width, obj.height); + if (obj.font) $obj.fontStyle(obj.font, Number(obj.fontSize), obj.fontColor); + if (obj._innerText) { + if (obj._innerText.match?.(/\$(.*)\./)) { + let state = findStateObject(obj._innerText); + let ref = state; + if (ref.includes('.')) { + let split = state.split('.'); + ref = split[0]; + split.shift(); + state = split.join('.'); + } + _this.wait(_this.parent, ref).then(_ => { + let [binds, text] = parseTextBindings(obj._innerText); + $obj.text(text); + _this.parent.bindState(ref == state ? _this.parent[ref] : getPropByString(_this.parent[ref], state), binds, $obj); + }); + } else if (obj._innerText.match?.(/\$(\w*)\/(\w*)/)) { + let [binds, text] = parseTextGlobalBindings(obj._innerText); + $obj.text(text); + _this.parent.bindState(AppState, binds, $obj); + } else { + $obj.text(obj._innerText); + } + } + return $obj; + break; + } + } + + function applyValues(obj, $obj) { + const callObjKeyVal = (key) => { + return new Promise(resolve => { + const applyValue = (val) => { + if (typeof $obj[key] === 'function') { + $obj[key](val); + } else { + $obj[key] = val; + } + }; + const callFn = async () => { + let val = isNaN(obj[key]) ? obj[key] : Number(obj[key]); + if (typeof val === 'string') { + if (val.match(/\$(.*)\./)) { + let stateStr = findStateObject(val); + let state = _this.parent[stateStr]; + if (!state) { + await _this.wait(_this.parent, stateStr); + state = _this.parent[stateStr]; + } + if (!!state.then) state = await state; + let [binds] = parseTextBindings(val); + return _this.parent.bindState(state, binds, (dataVal) => applyValue(dataVal)); + } else if (val.match(/\$(\w*)\/(\w*)/)) { + let [binds] = parseTextGlobalBindings(val); + return _this.parent.bindState(AppState, binds, (dataVal) => applyValue(dataVal)); + } + + if (val.startsWith('$') && val != '$element') { + return applyValue(_this.parent[val.slice(1)]); + } + } + + applyValue(val); + }; + if (_this.parent.__afterInitClass) { + return _this.parent.__afterInitClass.push(() => resolve(callFn())); + } + resolve(callFn()); + }); + }; + + for (let key in obj) { + if (key == 'shader') { + let shader = _this.initClass(Shader, obj[key], { + tMap: { value: null } + }); + if (window[shader.vsName]) window[shader.vsName]({}, shader); + $obj.useShader(shader); + // ShaderUIL.add(shader); + } + + if (obj.width && obj.height && $obj.size) { + $obj.size(!isNaN(obj.width) ? Number(obj.width) : obj.width, + !isNaN(obj.height) ? Number(obj.height) : obj.height); + } + + if (key == 'css' || key == 'transform') { + let data = {}; + obj[key].split(',').forEach(param => { + let [a, b] = param.split(':'); + a = a.trim(); + b = b.trim(); + if (!isNaN(b)) b = Number(b); + data[a] = b; + }); + $obj[key](data); + } else if (key == 'onClick' || key == 'onHover') { + _this.wait(_ => !!_this.parent[obj[key].slice(1)]).then(_ => { + const interactHandle = _this.parent[obj[key].slice(1)] + let hoverFn = key === 'onHover' ? interactHandle : null; + let clickFn = key === 'onClick' ? interactHandle : null; + $obj.interact(hoverFn, clickFn, obj["seoLink"], obj["seoText"]); + }) + } else { + if (typeof $obj[key] === 'function') { + if (key == 'size') { + let size = obj.size.split(','); + size.map(x => Number(x)); + $obj.size(size[0], size[1]); + continue; + } + if (obj[key] === 1) obj[key] = undefined; + callObjKeyVal(key); + } else { + callObjKeyVal(key); + } + } + + } + } + + function convertToUsableRef(str) { + if (str.startsWith('$')) str = str.slice(1); + let ref = str; + let state = str; + if (str.includes('.')) { + let split = str.split('.'); + state = split[0]; + split.shift(); + ref = split.join('.'); + } + + return [state, ref]; + } + + async function create(obj, parent) { + if (!isLowerCase(obj._type) && obj._type != 'GLObject' && obj._type != 'HydraObject' && obj._type != 'GLText' && obj._type != 'UI') { + let params = {}; + for (let key in obj) { + if (key == '_type' || key == 'refName' || key == 'children' || key == 'display') continue; + params[key] = obj[key]; + + if (params[key].match?.(/\$(.*)\./)) { + await defer(); + let [state, ref] = convertToUsableRef(params[key]); + params[key] = state == ref ? + _this.parent[state] : + _this.parent[ref] ? + _this.parent[ref] : + getPropByString(_this.parent[state], ref); + } else if (params[key].startsWith('$')) { + await defer(); + params[key] = _this.parent[params[key].slice(1)]; + } + } + + if (obj._type == 'ViewState') { + params.__parent = parent; + } + _this.parent[obj.refName] = _this.parent.initClass(window[obj._type], AppState.createLocal(params), [parent]); + return; + } + + let $obj = doConstructor(obj); + if (obj.addTo) { + let addTo = obj.addTo.includes('.') ? eval(obj.addTo) : _this.parent.element; + addTo.add($obj); + } else { + if (parent) parent.add($obj); + } + + applyValues(obj, $obj); + + $obj.transform?.(); + + if (obj.refName) { + _this.parent[obj.refName] = $obj; + } + + obj.children.forEach(o => create(o, $obj)); + } + +}); +/** + * @name LinkedList + * + * @constructor + */ + +Class(function LinkedList() { + var prototype = LinkedList.prototype; + + /** + * @name length + * @memberof LinkedList + */ + this.length = 0; + this.first = null; + this.last = null; + this.current = null; + this.prev = null; + + if (typeof prototype.push !== 'undefined') return; + + /** + * @name push + * @memberof LinkedList + * + * @function + * @param {*} obj + */ + prototype.push = function(obj) { + if (!this.first) { + this.first = obj; + this.last = obj; + obj.__prev = obj; + obj.__next = obj; + } else { + obj.__next = this.first; + obj.__prev = this.last; + this.last.__next = obj; + this.last = obj; + } + + this.length++; + }; + + /** + * @name remove + * @memberof LinkedList + * + * @function + * @param {*} obj + */ + prototype.remove = function(obj) { + if (!obj || !obj.__next) return; + + if (this.length <= 1) { + this.empty(); + } else { + if (obj == this.first) { + this.first = obj.__next; + this.last.__next = this.first; + this.first.__prev = this.last; + } else if (obj == this.last) { + this.last = obj.__prev; + this.last.__next = this.first; + this.first.__prev = this.last; + } else { + obj.__prev.__next = obj.__next; + obj.__next.__prev = obj.__prev; + } + + this.length--; + } + + obj.__prev = null; + obj.__next = null; + }; + + /** + * @name empty + * @memberof LinkedList + * + * @function + */ + prototype.empty = function() { + this.first = null; + this.last = null; + this.current = null; + this.prev = null; + this.length = 0; + }; + + /** + * @name start + * @memberof LinkedList + * + * @function + * @return {*} + */ + prototype.start = function() { + this.current = this.first; + this.prev = this.current; + return this.current; + }; + + /** + * @name next + * @memberof LinkedList + * + * @function + * @return {*} + */ + prototype.next = function() { + if (!this.current) return; + this.current = this.current.__next; + if (this.length == 1 || this.prev.__next == this.first) return; + this.prev = this.current; + return this.current; + }; + + /** + * @name destroy + * @memberof LinkedList + * + * @function + * @returns {Null} + */ + prototype.destroy = function() { + Utils.nullObject(this); + return null; + }; + +}); +/** + * @name ObjectPool + * + * @constructor + * @param {Constructor} [_type] + * @param {Number} [_number = 10] - Only applied if _type argument exists + */ + +Class(function ObjectPool(_type, _number = 10) { + var _pool = []; + + /** + * Pool array + * @name array + * @memberof ObjectPool + */ + this.array = _pool; + + //*** Constructor + (function() { + if (_type) for (var i = 0; i < _number; i++) _pool.push(new _type()); + })(); + + //*** Public Methods + + /** + * Retrieve next object from pool + * @name get + * @memberof ObjectPool + * + * @function + * @returns {ArrayElement|null} + */ + this.get = function() { + return _pool.shift() || (_type ? new _type() : null); + }; + + /** + * Empties pool array + * @name empty + * @memberof ObjectPool + * + * @function + */ + this.empty = function() { + _pool.length = 0; + }; + + /** + * Place object into pool + * @name put + * @memberof ObjectPool + * + * @function + * @param {Object} obj + */ + this.put = function(obj) { + if (obj && !_pool.includes(obj)) _pool.push(obj); + }; + + /** + * Insert array elements into pool + * @name insert + * @memberof ObjectPool + * + * @function + * @param {Array} array + */ + this.insert = function(array) { + if (typeof array.push === 'undefined') array = [array]; + for (var i = 0; i < array.length; i++) this.put(array[i]); + }; + + /** + * Retrieve pool length + * @name length + * @memberof ObjectPool + * + * @function + * @returns {Number} + */ + this.length = function() { + return _pool.length; + }; + + /** + * Randomize pool + * @memberof ObjectPool + * + * @function + */ + this.randomize = function() { + let array = _pool; + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + } + + /** + * Calls destroy method on all members if exists, then removes reference. + * @name destroy + * @memberof ObjectPool + * + * @function + * @returns {null} + */ + this.destroy = function() { + for (let i = _pool.length - 1; i >= 0; i--) if (_pool[i].destroy) _pool[i].destroy(); + return _pool = null; + }; +}); + +/** + * @name Gate + * + * @constructor + */ + + +Class(function Gate() { + var _this = this; + + var _list = []; + var _map = {}; + + //*** Event handlers + + //*** Public methods + /** + * @name this.create + * @memberof Gate + * + * @function + * @param name + */ + this.create = function(name) { + let promise = Promise.create(); + if (name) _map[name] = promise; + else _list.push(promise); + } + + /** + * @name this.open + * @memberof Gate + * + * @function + * @param name + */ + this.open = function(name) { + if (name) { + if (!_map[name]) _map[name] = Promise.create(); + _map[name].resolve(); + } + + let promise = _list.shift(); + if (promise) promise.resolve(); + } + + /** + * @name this.wait + * @memberof Gate + * + * @function + * @param name + */ + this.wait = function(name) { + if (!_list.length && !name) return Promise.resolve(); + + if (name) { + if (!_map[name]) _map[name] = Promise.create(); + return _map[name]; + } + + return _list[_list.length-1] || Promise.resolve(); + } +}, 'static'); +/** + * @name Assets + */ + +Class(function Assets() { + const _this = this; + const _fetchCors = {mode: 'cors'}; + + this.__loaded = []; + + /** + * Flip bitmap images when decoding. + * @name Assets.FLIPY + * @memberof Assets + * @example + * Assets.FLIPY = false // do not flip when decoding + */ + this.FLIPY = true; + + /** + * Path for Content Distribution Network (eg. Amazon bucket) + * @name Assets.CDN + * @memberof Assets + * @example + * Assets.CDN = '//amazonbucket.com/project/'; + */ + this.CDN = ''; + + /** + * Cross Origin string to apply to images + * @name Assets.CORS + * @memberof Assets + * @example + * Assets.CORS = ''; + */ + this.CORS = 'anonymous'; + + /** + * Storage for all images loaded for easy access + * @name Assets.IMAGES + * @memberof Assets + */ + this.IMAGES = {}; + + /** + * Storage for all videos loaded for easy access + * @name Assets.VIDEOS + * @memberof Assets + */ + this.VIDEOS = {}; + + /** + * Storage for all audios loaded for easy access + * @name Assets.AUDIOS + * @memberof Assets + */ + this.AUDIOS = {}; + + /** + * Storage for all sdf font files loaded for easy access + * @name Assets.SDF + * @memberof Assets + */ + this.SDF = {}; + + /** + * Storage for all JSON files loaded for easy access. Always clones object when retrieved. + * @name Assets.JSON + * @memberof Assets + */ + this.JSON = { + push: function(prop, value) { + this[prop] = value; + Object.defineProperty(this, prop, { + get: () => {return JSON.parse(JSON.stringify(value))}, + }); + } + }; + + Object.defineProperty(this.JSON, 'push', { + enumerable: false, + writable: true + }); + + /** + * Storage for all SVG files loaded for easy access + * @name Assets.SVG + * @memberof Assets + */ + this.SVG = {}; + + /** + * Returns pixel-ratio-appropriate version if exists + * @private + * @param path + * @returns {String} + */ + function parseResolution(path) { + if (!window.ASSETS || !ASSETS.RES) return path; + var res = ASSETS.RES[path]; + var ratio = Math.min(Device.pixelRatio, 3); + if (!res) return path; + if (!res['x' + ratio]) return path; + var split = path.split('/'); + var file = split[split.length-1]; + split = file.split('.'); + return path.replace(file, split[0] + '-' + ratio + 'x.' + split[1]); + } + + /** + * Array extension for manipulating list of assets + * @private + * @param {Array} arr + * @returns {AssetList} + * @constructor + */ + function AssetList(arr) { + arr.__proto__ = AssetList.prototype; + return arr; + } + AssetList.prototype = new Array; + + /** + * Filter asset list to only include those matching the arguments + * @param {String|String[]} items + */ + AssetList.prototype.filter = function(items) { + for (let i = this.length - 1; i >= 0; i--) if (!this[i].includes(items)) this.splice(i, 1); + return this; + }; + + /** + * Filter asset list to exclude those matching the arguments + * @param {String|String[]} items + */ + AssetList.prototype.exclude = function(items) { + for (let i = this.length - 1; i >= 0; i--) if (this[i].includes(items)) this.splice(i, 1); + return this; + }; + + AssetList.prototype.prepend = function(prefix) { + for (let i = this.length - 1; i >= 0; i--) this[i] = prefix + this[i]; + return this; + }; + + AssetList.prototype.append = function(suffix) { + for (let i = this.length - 1; i >= 0; i--) this[i] = this[i] + suffix; + return this; + }; + + /** + * Get compiled list of assets + * @name Assets.list + * @memberof Assets + * + * @function + * @returns {AssetList} + * @example + * const assets = Assets.list(); + * assets.filter(['images', 'geometry']); + * assets.exclude('mobile'); + * assets.append('?' + Utils.timestamp()); + * const loader = _this.initClass(AssetLoader, assets); + */ + this.list = function() { + if (!window.ASSETS) console.warn(`ASSETS list not available`); + return new AssetList(window.ASSETS.slice(0) || []); + }; + + /** + * Wrap path in CDN and get correct resolution file + * @name Assets.getPath + * @memberof Assets + * + * @function + * @param {String} path + * @returns {String} + */ + + this.BASE_PATH = ''; + + this.getPath = function(path) { + + if (path.includes('~')) return _this.BASE_PATH + path.replace('~', ''); + + // If static url, return untouched + if (path.includes('//')) return path; + + // Check if should offer different DPR version + path = parseResolution(path); + + // Check if the asset's path should be replaced with a different path. + // Doesnt use a CDN. + if (_this.replacementPaths) { + for (let pathKey in _this.replacementPaths) { + if(path.startsWith(pathKey)) { + path = path.replace(pathKey, _this.replacementPaths[pathKey]); + return path; + } + } + } + + if (_this.dictionary) { + for (let pathKey in _this.dictionary) { + if (_this.dictionary[pathKey].includes(path.split('?')[0])) return pathKey + path; + } + } + + // Wrap in CDN + if (this.CDN && !~path.indexOf(this.CDN)) path = this.CDN + path; + + return path; + }; + + /** + * Replace an assets path before trying to load it. Ie, if an asset has a url of /dam/content/assets/ and you want it to become /images/, call this function with ('/dam/content/assets/', '/images'); + * This function should only be used as a last resort. In some cases, when integrating in a system like AEM, it's pretty unavoidable. + * @param {string} path + * @param {string} replacedPath + */ + this.registerPathReplacement = function(path, replacedPath) { + if (!_this.replacementPaths) _this.replacementPaths = {}; + _this.replacementPaths[path] = replacedPath; + } + + this.registerPath = function(path, assets) { + if (!_this.dictionary) _this.dictionary = {}; + _this.dictionary[path] = assets; + }; + + /** + * Load image, adding CDN and CORS state and optionally storing in memory + * @name Assets.loadImage + * @memberof Assets + * + * @function + * @param {String} path - path of asset + * @param {Boolean} [isStore] - True if to store in memory under Assets.IMAGES + * @returns {Image} + * @example + * Assets.loadImage('assets/images/cube.jpg', true); + * console.log(Assets.IMAGES['assets/images/cube.jpg']); + */ + this.loadImage = function(path, isStore) { + var img = new Image(); + img.crossOrigin = this.CORS; + img.src = _this.getPath(path); + + img.loadPromise = function() { + let promise = Promise.create(); + img.onload = promise.resolve; + return promise; + }; + + if (isStore) this.IMAGES[path] = img; + + return img; + }; + + /** + * Load and decode an image off the main thread + * @name Assets.decodeImage + * @memberof Assets + * + * @function + * @param {String} path - path of asset + * @param {Boolean} [flipY=Assets.FLIPY] - overwrite global flipY option + * @returns {Promise} + * @example + * Assets.decodeImage('assets/images/cube.jpg').then(imgBmp => {}); + */ + this.decodeImage = function(path, params, promise) { + if ( !promise ) promise = Promise.create(); + let img = _this.loadImage(path); + img.onload = () => promise.resolve(img); + img.onerror = () => _this.decodeImage('assets/images/_scenelayout/uv.jpg', params, promise); + return promise; + }; + + /** + * Detects webp support, returns boolean + * @name Assets.supportsWebP + * @memberof Assets + * + * @function + * @returns {Boolean} + * @example + * let supportsWebP = Assets.supportsWebP(); + * > true + */ + const _supportsWebP = (function () { + try { + let canvas = document.createElement('canvas'); + return canvas.toDataURL('image/webp').indexOf('data:image/webp') == 0; + } catch (e) { + return false; + } + })(); + + this.supportsWebP = function () { + return !!_supportsWebP; + } + + /** + * Detects webp support, returns best image path + * @name Assets.perfImage + * @memberof Assets + * + * @function + * @returns {String} + * @example + * let path = Assets.perfImage('assets/images/cube.jpg'); + * console.log(path); + * > "assets/images/cube.webp" + */ + this.perfImage = function(path) { + let result = path; + if ( _this.supportsWebP() && path.includes(['.jpg', '.png'])) result = `${path.substring(0, path.lastIndexOf('.'))}.webp`; + return result; + } + +}, 'static'); + +/** + * @name AssetLoader + * @example + * const assets = Assets.list()l + * const loader = new AssetLoader(assets); + * _this.events.sub(loader, Events.COMPLETE, complete); + */ + +Class(function AssetLoader(_assets, _callback, ASSETS = Assets) { + Inherit(this, Events); + const _this = this; + + let _total = _assets.length; + let _loaded = 0; + let _lastFiredPercent = 0; + + (function() { + if (!Array.isArray(_assets)) throw `AssetLoader requires array of assets to load`; + _assets = _assets.slice(0).reverse(); + + init(); + })(); + + function init() { + if (!_assets.length) return complete(); + for (let i = 0; i < AssetLoader.SPLIT; i++) { + if (_assets.length) loadAsset(); + } + } + + function loadAsset() { + let path = _assets.splice(_assets.length - 1, 1)[0]; + + const name = path.split('assets/').last().split('.')[0]; + const ext = path.split('.').last().split('?')[0].toLowerCase(); + + let timeout = Timer.create(timedOut, AssetLoader.TIMEOUT, path); + + // Check if asset previously loaded + if (!Assets.preventCache && !!~Assets.__loaded.indexOf(path)) return loaded(); + + // If image, don't use fetch api + if (ext.includes(['jpg', 'jpeg', 'png', 'gif'])) { + let image = ASSETS.loadImage(path); + if (image.complete) return loaded(); + image.onload = loaded; + image.onerror = loaded; + return; + } + + // If video, do manual request and create blob + if (ext.includes(['mp4', 'webm'])) { + fetch(path).then(async response => { + let blob = await response.blob(); + Assets.VIDEOS[name] = URL.createObjectURL(blob); + loaded(); + }).catch(e => { + console.warn(e); + loaded(); + }); + return; + } + + // If audio, do manual request and create blob + if (ext.includes(['mp3'])) { + fetch(path).then(async response => { + let blob = await response.blob(); + Assets.AUDIOS[name] = URL.createObjectURL(blob); + loaded(); + }).catch(e => { + console.warn(e); + loaded(); + }); + return; + } + + get(Assets.getPath(path), Assets.HEADERS).then(data => { + Assets.__loaded.push(path); + if (ext == 'json') ASSETS.JSON.push(name, data); + if (ext == 'svg') ASSETS.SVG[name] = data; + if (ext == 'fnt') ASSETS.SDF[name.split('/')[1]] = data; + if (ext == 'js') window.eval(data); + if (ext.includes(['fs', 'vs', 'glsl']) && window.Shaders) Shaders.parse(data, path); + loaded(); + }).catch(e => { + console.warn(e); + loaded(); + }); + + function loaded() { + if (timeout) clearTimeout(timeout); + increment(); + if (_assets.length) loadAsset(); + } + } + + function increment() { + let percent = Math.max(_lastFiredPercent, Math.min(1, ++_loaded / _total)); + _this.events.fire(Events.PROGRESS, {percent}); + _lastFiredPercent = percent; + + // Defer to get out of promise error catching + if (_loaded >= _total) defer(complete); + } + + function complete() { + if (_this.completed) return; + _this.completed = true; + + // Defer again to allow any code waiting for loaded libs to run first + defer(() => { + _callback && _callback(); + _this.events.fire(Events.COMPLETE); + }); + } + + function timedOut(path) { + console.warn('Asset timed out', path); + } + + this.loadModules = function() { + if (!window._BUILT_) return; + this.add(1); + let module = window._ES5_ ? 'es5-modules' : 'modules'; + let src = 'assets/js/'+module+'.js?' + window._CACHE_; + // Use along with + + + + + diff --git a/Build/sw.js b/Build/sw.js new file mode 100644 index 0000000..5c14890 --- /dev/null +++ b/Build/sw.js @@ -0,0 +1,59 @@ +var _assets; +var _needsCache; +var _channel; + +function initCache() { + _needsCache = false; + caches.open('v1').then(cache => { + return cache.addAll(_assets); + }); +} + +function handleUpload(data) { + _assets = []; + data.assets.forEach(path => { + if (path.includes(data.hostname)) _assets.push(path); + else _assets.push(new Request(path)); + }); + + data.sw.forEach(path => { + _assets.push(new Request(data.cdn + path)); + }); + + if (data.offline) _assets.push('/'); + + if (_needsCache) initCache(); +} + +function clearCache(data) { + caches.delete('v1'); +} + +function emit(event, data = {}) { + data.evt = event; + _channel.postMessage(data); +} + +self.addEventListener('install', function(e) { + self.skipWaiting(); + if (_assets) e.waitUntil(initCache); + else _needsCache = true; +}); + +self.addEventListener('fetch', function(e) { + if (!e.request.url.includes('/assets/')) return; + e.respondWith( + caches.match(e.request).then(response => { + return response || fetch(e.request); + }) + ); +}); + +self.addEventListener('message', function(e) { + _channel = e.ports[0]; + let data = e.data; + switch (data.fn) { + case 'upload': handleUpload(data); break; + case 'clearCache': clearCache(data); break; + } +}); \ No newline at end of file